diff --git a/CLAUDE.md b/CLAUDE.md index d36c20b..b4e9de0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,51 @@ Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123! - Global query filters enforce company isolation at database level - Users have both system role (SuperAdmin) and company role (CompanyAdmin, Manager, Worker, Viewer) +## Data Access Rules (ENFORCE THESE) + +> **`ApplicationDbContext` is NEVER injected into a controller.** +> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below. +> **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: + +**Tier 1 — Simple CRUD** → `IUnitOfWork.EntityName` (generic `IRepository`) +```csharp +var items = await _unitOfWork.CatalogItems.GetAllAsync(); +await _unitOfWork.Announcements.AddAsync(entity); +await _unitOfWork.CompleteAsync(); +``` + +**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork` +```csharp +// Include chains and domain-specific queries belong in the repository, not the controller +var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id); +var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id); +var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token); +``` +Typed repositories: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`, +`ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository` +— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/` + +**Tier 3 — Aggregate/reporting queries** → injected read services +```csharp +// P&L, AR aging, cycle time, powder usage — shaped DTOs, never tracked entities +var aging = await _financialReports.GetArAgingAsync(companyId); +``` +Services: `IFinancialReportService`, `IOperationalReportService` +— defined in `Core/Interfaces/Services/`, implemented in `Infrastructure/Services/` + +### Permanent exceptions (ApplicationDbContext allowed — intentional, documented): +`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`, +`DataExportController`, `AccountDataExportController`, `DataPurgeController`, +`SystemInfoController`, `SystemLogsController`, `CompanyHealthController` + +If you think you need a new exception, you almost certainly don't. Check the spec first. + +--- + ## Data Access Patterns ### Common Controller Pattern diff --git a/docs/DATA_ACCESS_ARCHITECTURE.md b/docs/DATA_ACCESS_ARCHITECTURE.md new file mode 100644 index 0000000..cd68102 --- /dev/null +++ b/docs/DATA_ACCESS_ARCHITECTURE.md @@ -0,0 +1,346 @@ +# Data Access Architecture + +## 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. + +--- + +## The Problem + +The codebase currently has ~50 controllers injecting `ApplicationDbContext` directly alongside +`IUnitOfWork`. This happened organically: the generic `Repository` could not express complex +multi-level include queries, so `_context` became the escape hatch. Once injected for one complex +query, it was used for everything else in that controller too. The inconsistency compounds with +every new controller a developer writes. + +For a solo developer this is manageable. For a team it creates a daily decision tax — "which +pattern do I follow?" — with no clear answer. New developers copy the nearest example, which is +usually `_context`, so the problem grows. + +--- + +## The Rule (Short Version) + +> **`ApplicationDbContext` is never injected into a controller. Ever.** +> +> All data access in controllers goes through `IUnitOfWork`. +> Complex queries that the generic `Repository` cannot express live in typed repositories or +> read services — both accessible through `IUnitOfWork`. + +--- + +## Target Architecture + +``` +Controllers (Presentation Layer) + │ + ├── IUnitOfWork.EntityName → IRepository Simple CRUD + ├── IUnitOfWork.Jobs → IJobRepository Complex domain queries + ├── IUnitOfWork.Invoices → IInvoiceRepository Complex domain queries + ├── IUnitOfWork.Quotes → IQuoteRepository Complex domain queries + ├── IUnitOfWork.Customers → ICustomerRepository Complex domain queries + ├── IUnitOfWork.Bills → IBillRepository Complex domain queries + │ + ├── IFinancialReportService Aggregate/reporting reads + └── IOperationalReportService Aggregate/reporting reads + +Infrastructure Layer (the only layer that knows about ApplicationDbContext) + │ + ├── Repository Generic implementation + ├── JobRepository : IJobRepository Typed implementations + ├── InvoiceRepository ... + ├── QuoteRepository ... + ├── CustomerRepository ... + ├── BillRepository ... + │ + ├── FinancialReportService DbContext used directly (read-only, no tracking) + └── OperationalReportService DbContext used directly (read-only, no tracking) +``` + +`ApplicationDbContext` never crosses into the Presentation layer. It lives in Infrastructure and +only Infrastructure. + +--- + +## Three Tiers of Data Access + +### Tier 1 — Simple CRUD → Generic `IRepository` via `IUnitOfWork` + +Use for: single-entity lookups, lists, adds, soft deletes, simple filtered queries. + +```csharp +// Good +var items = await _unitOfWork.CatalogItems.GetAllAsync(); +var item = await _unitOfWork.CatalogItems.GetByIdAsync(id); +await _unitOfWork.Announcements.AddAsync(entity); +await _unitOfWork.CompleteAsync(); +``` + +Entities in this tier (generic repo is sufficient): +- Announcements, BugReports, CatalogItems, CatalogCategories, CatalogPriceCheckReports +- CompanyBlastSetups, CompanyOperatingCosts, CompanyPreferences +- ContactSubmissions, CreditMemos, CreditMemoApplications +- DashboardTips, Deposits, Equipment +- GiftCertificates, GiftCertificateRedemptions +- InventoryItems, InventoryTransactions +- JobChangeHistories, JobDailyPriorities, JobItemCoats, JobItems, JobNotes, JobPhotos +- JobStatusHistory, JobTemplates, JobTemplateItems, JobTemplateItemCoats, JobTemplateItemPrepServices +- JobTimeEntries, MaintenanceRecords, ManufacturerLookupPatterns +- NotificationLogs, NotificationTemplates +- OvenBatches, OvenBatchItems, OvenCosts +- Payments, PrepServices, PricingTiers +- PowderUsageLogs, PurchaseOrderItems +- QuoteChangeHistories, QuoteItemCoats, QuoteItems, QuoteItemPrepServices, QuotePhotos +- Refunds, ReworkRecords +- ShopWorkers, ShopWorkerRoleCosts, SubscriptionPlanConfigs +- Vendors + +### Tier 2 — Complex Domain Queries → Typed Repositories + +Use for: multi-level include chains, domain-specific filtered loads, queries that require +`IgnoreQueryFilters`, queries that span multiple related entities in non-trivial ways. + +The typed repository interface lives in `Core/Interfaces/Repositories/`. +The implementation lives in `Infrastructure/Repositories/`. +The property is on `IUnitOfWork` — same access point as Tier 1. + +```csharp +// Good — the complex include chain lives in the repository, not the controller +var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id); +var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id); +var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token); +``` + +#### `IJobRepository` +| Method | Purpose | +|--------|---------| +| `LoadForDetailsAsync(int id)` | Full include chain for Job Details view | +| `LoadForEditAsync(int id)` | Includes needed for Job Edit form | +| `LoadForBoardAsync(int companyId, ...)` | Jobs for the kanban board with status/priority filters | +| `GetByStatusAsync(int companyId, int statusId)` | Filtered by status with customer include | +| `GetAssignedToWorkerAsync(int workerId)` | All active jobs for a worker | +| `GetOverdueAsync(int companyId)` | Jobs past due date | + +#### `IInvoiceRepository` +| Method | Purpose | +|--------|---------| +| `LoadForViewAsync(int id)` | Full 8-table include chain (current `LoadInvoiceForViewAsync`) | +| `GetOverdueAsync(int companyId)` | Invoices past due date with customer info | +| `GetByPaymentTokenAsync(string token)` | Online payment portal lookup | +| `GetForJobAsync(int jobId, bool includeDeleted)` | Invoice for a given job (1:1 check) | + +#### `IQuoteRepository` +| Method | Purpose | +|--------|---------| +| `LoadForViewAsync(int id)` | Full include chain for Quote Details | +| `LoadForEditAsync(int id)` | Includes needed for wizard edit | +| `GetByApprovalTokenAsync(string token)` | Customer approval portal lookup | +| `GetPendingApprovalsAsync(int companyId)` | Quotes awaiting customer approval | + +#### `ICustomerRepository` +| Method | Purpose | +|--------|---------| +| `LoadForDetailsAsync(int id)` | Customer with jobs, quotes, invoices, notes summary | +| `GetWithOutstandingBalancesAsync(int companyId)` | AR summary data | +| `FindByEmailAsync(string email, int companyId)` | Duplicate check on create/edit | + +#### `IBillRepository` +| Method | Purpose | +|--------|---------| +| `LoadForViewAsync(int id)` | Bill with line items, payments, vendor | +| `GetApPayablesAsync(int companyId)` | Open AP ledger with aging | + +#### `IPurchaseOrderRepository` +| Method | Purpose | +|--------|---------| +| `LoadForViewAsync(int id)` | PO with line items and vendor | +| `GetByStatusAsync(int companyId, string status)` | Filtered PO list | + +### Tier 3 — Aggregate/Reporting Queries → Read Services + +Use for: P&L calculations, AR aging, powder usage aggregates, job cycle time, any query that +uses `GROUP BY`, window functions, or multi-table joins that return shaped result DTOs rather +than tracked entities. + +These services are injected directly into controllers alongside `IUnitOfWork`. They use +`ApplicationDbContext` internally (with `.AsNoTracking()`) — that is correct and intentional, +because they live in Infrastructure. + +```csharp +// Controller constructor +public ReportsController(IUnitOfWork unitOfWork, IFinancialReportService financialReports, ...) + +// Usage +var aging = await _financialReports.GetArAgingAsync(companyId); +var pl = await _financialReports.GetProfitLossAsync(companyId, startDate, endDate); +``` + +#### `IFinancialReportService` +- `GetArAgingAsync(int companyId)` → AR aging buckets (current, 30, 60, 90+ days) +- `GetProfitLossAsync(int companyId, DateTime start, DateTime end)` → P&L summary +- `GetMonthlyRevenueAsync(int companyId, int months)` → monthly invoiced vs collected +- `GetTopOutstandingCustomersAsync(int companyId, int count)` → largest open balances +- `GetCashFlowProjectionAsync(int companyId, int days)` → forward-looking cash position +- `GetAnomaliesAsync(int companyId, int lookbackDays)` → bill/expense anomaly detection +- `GetRecentPaymentsAsync(int companyId, int count)` → recent payment activity + +#### `IOperationalReportService` +- `GetJobCycleTimeAsync(int companyId, DateTime start, DateTime end)` → avg days per stage +- `GetPowderUsageAsync(int companyId, DateTime start, DateTime end)` → usage by color/vendor +- `GetWorkerProductivityAsync(int companyId, DateTime start, DateTime end)` → jobs per worker +- `GetOvenUtilizationAsync(int companyId, DateTime start, DateTime end)` → oven throughput +- `GetReworkRateAsync(int companyId, DateTime start, DateTime end)` → defect/rework trends +- `GetStatusFlowAsync(int companyId, DateTime start, DateTime end)` → job status transitions + +--- + +## Permanent Exceptions + +The following controllers are **intentionally allowed** to inject `ApplicationDbContext` directly. +This is not a smell — it is correct for their use cases. Each file has a comment explaining why. + +| Controller | Reason | +|------------|--------| +| `StripeWebhookController` | Idempotency key lookup must bypass soft-delete and tenant filters | +| `WebhooksController` | Twilio raw event handling; same reasoning as Stripe | +| `PaymentController` | Stripe Connect embedded payment flow; raw session state needed | +| `RegistrationController` | PendingRegistrationSession queries bypass normal tenant scoping | +| `DataExportController` | Bulk streaming export; repository pattern adds unnecessary overhead | +| `AccountDataExportController` | Same as above | +| `DataPurgeController` | Destructive bulk operations; needs direct transaction control | +| `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. + +--- + +## Migration Roadmap + +### Phase 1 — Foundation (no behavior change) +- [ ] Create `Core/Interfaces/Repositories/` directory +- [ ] Define `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`, `ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository` +- [ ] Define `IFinancialReportService`, `IOperationalReportService` in `Core/Interfaces/Services/` +- [ ] Create `Infrastructure/Repositories/` directory +- [ ] Implement all typed repositories (move include chains from controllers) +- [ ] Implement `FinancialReportService` (move aggregate queries from `ReportsController`) +- [ ] Implement `OperationalReportService` +- [ ] Extend `IUnitOfWork` with typed repository properties +- [ ] Register all new types in `Program.cs` +- [ ] Build passes, all tests green — no controller has changed yet + +### 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 ✓ COMPLETE (2026-04-28) +Remove `ApplicationDbContext` injection from all controllers not in the permanent exceptions list, +replacing with existing `IUnitOfWork` generic repository calls. + +- [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 ✓ 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) + +--- + +## File Locations Reference + +``` +src/ + PowderCoating.Core/ + Interfaces/ + IRepository.cs existing + IUnitOfWork.cs existing — extended in Phase 1 + Repositories/ NEW in Phase 1 + IJobRepository.cs + IInvoiceRepository.cs + IQuoteRepository.cs + ICustomerRepository.cs + IBillRepository.cs + IPurchaseOrderRepository.cs + Services/ NEW in Phase 1 + IFinancialReportService.cs + IOperationalReportService.cs + + PowderCoating.Infrastructure/ + Repositories/ NEW in Phase 1 + UnitOfWork.cs existing — extended + Repository.cs existing + JobRepository.cs + InvoiceRepository.cs + QuoteRepository.cs + CustomerRepository.cs + BillRepository.cs + PurchaseOrderRepository.cs + Services/ + FinancialReportService.cs NEW in Phase 1 + OperationalReportService.cs NEW in Phase 1 + NotificationService.cs existing — correct as-is + PdfService.cs existing — correct as-is +``` + +--- + +## Code Review Checklist + +When reviewing a PR that touches data access: + +1. Does the controller inject `ApplicationDbContext`? If yes and it's not in the permanent + exceptions list → request changes. +2. Is a complex include chain written inline in a controller action? → move to typed repository. +3. Is a GROUP BY / aggregate query inline in a controller action? → move to report service. +4. Does a new typed repository method duplicate logic already in another repository? → consolidate. +5. Are all DbContext calls in report services using `.AsNoTracking()`? → required for read services. 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/DTOs/Job/JobDtos.cs b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs index a1ad86c..e95a0d9 100644 --- a/src/PowderCoating.Application/DTOs/Job/JobDtos.cs +++ b/src/PowderCoating.Application/DTOs/Job/JobDtos.cs @@ -99,6 +99,7 @@ public class JobListDto public string PriorityDisplayName { get; set; } = string.Empty; public string PriorityColorClass { get; set; } = "secondary"; + public bool CustomerNotifyByEmail { get; set; } = true; public DateTime? ScheduledDate { get; set; } public DateTime? DueDate { get; set; } public decimal FinalPrice { get; set; } diff --git a/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs b/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs index 38760cd..b097146 100644 --- a/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs +++ b/src/PowderCoating.Application/DTOs/Notification/NotificationLogDtos.cs @@ -10,6 +10,7 @@ public class NotificationLogDto public NotificationType NotificationType { get; set; } public string NotificationTypeDisplay => NotificationType switch { + NotificationType.AdminEmail => "Admin Email", NotificationType.QuoteSent => "Quote Sent", NotificationType.QuoteApproved => "Quote Approved", NotificationType.JobStatusChanged => "Job Status Changed", diff --git a/src/PowderCoating.Application/DTOs/Wizard/WizardDtos.cs b/src/PowderCoating.Application/DTOs/Wizard/WizardDtos.cs index dfb9b58..e559ab1 100644 --- a/src/PowderCoating.Application/DTOs/Wizard/WizardDtos.cs +++ b/src/PowderCoating.Application/DTOs/Wizard/WizardDtos.cs @@ -12,7 +12,7 @@ public class WizardProgressDto public bool Completed { get; set; } public List DoneSteps { get; set; } = new(); public List SkippedSteps { get; set; } = new(); - public const int TotalSteps = 10; + public const int TotalSteps = 5; public bool IsStepDone(int step) => DoneSteps.Contains(step); public bool IsStepSkipped(int step) => SkippedSteps.Contains(step); 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/IFinancialReportService.cs b/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs new file mode 100644 index 0000000..7537dc1 --- /dev/null +++ b/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs @@ -0,0 +1,23 @@ +using PowderCoating.Application.DTOs.Accounting; + +namespace PowderCoating.Application.Interfaces; + +/// +/// Read-only service for financial aggregate reports. All methods query the database +/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned. +/// Implemented in Infrastructure; uses ApplicationDbContext directly. +/// +public interface IFinancialReportService +{ + /// Returns a Profit & Loss report for the given company and date range. + Task GetProfitAndLossAsync(int companyId, DateTime from, DateTime to); + + /// Returns a Balance Sheet snapshot as of the given date. + Task GetBalanceSheetAsync(int companyId, DateTime asOf); + + /// Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days. + Task GetArAgingAsync(int companyId, DateTime asOf); + + /// Returns a Sales & Income report for the given company and date range. + Task GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to); +} diff --git a/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs b/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs new file mode 100644 index 0000000..21544f6 --- /dev/null +++ b/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs @@ -0,0 +1,48 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Application.Interfaces; + +/// +/// Placeholder return types for operational reports. These will be replaced with proper +/// Application DTOs as each report is migrated from ReportsController in Phase 2/3. +/// +public record JobCycleTimeReport(List Rows, int Months); +public record JobCycleTimeRow(string StatusName, double AvgDaysInStatus, int JobCount); + +public record PowderUsageReport(List Rows, int Months); +public record PowderUsageRow(string ColorName, string VendorName, decimal TotalLbs, decimal TotalCost); + +/// +/// Read-only service for operational aggregate reports. All methods query the database +/// with AsNoTracking and return pre-shaped objects — no tracked entities are returned. +/// Implemented in Infrastructure; uses ApplicationDbContext directly. +/// +public interface IOperationalReportService +{ + /// Returns average time jobs spend in each status over the given lookback period. + Task GetJobCycleTimeAsync(int companyId, int months); + + /// 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.Application/Mappings/InvoiceProfile.cs b/src/PowderCoating.Application/Mappings/InvoiceProfile.cs index e18f687..8e1fe57 100644 --- a/src/PowderCoating.Application/Mappings/InvoiceProfile.cs +++ b/src/PowderCoating.Application/Mappings/InvoiceProfile.cs @@ -33,6 +33,10 @@ public class InvoiceProfile : Profile .ForMember(d => d.BalanceDue, o => o.MapFrom(s => s.BalanceDue)) .ForMember(d => d.SalesTaxAccountName, o => o.MapFrom(s => s.SalesTaxAccount != null ? $"{s.SalesTaxAccount.AccountNumber} – {s.SalesTaxAccount.Name}" : null)) + // These three collections are built manually in BuildInvoiceDtoAsync — no AutoMapper element map exists. + // AutoMapper 12+ throws for non-empty collections with no registered element mapping, so Ignore here. + .ForMember(d => d.Refunds, o => o.Ignore()) + .ForMember(d => d.CreditApplications, o => o.Ignore()) .ForMember(d => d.GiftCertificateRedemptions, o => o.Ignore()); CreateMap() diff --git a/src/PowderCoating.Application/Mappings/JobProfile.cs b/src/PowderCoating.Application/Mappings/JobProfile.cs index ff20718..4da668b 100644 --- a/src/PowderCoating.Application/Mappings/JobProfile.cs +++ b/src/PowderCoating.Application/Mappings/JobProfile.cs @@ -109,7 +109,9 @@ public class JobProfile : Profile .ForMember(dest => dest.JobPriorityId, opt => opt.MapFrom(src => src.JobPriorityId)) .ForMember(dest => dest.PriorityCode, opt => opt.MapFrom(src => src.JobPriority.PriorityCode)) .ForMember(dest => dest.PriorityDisplayName, opt => opt.MapFrom(src => src.JobPriority.DisplayName)) - .ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass)); + .ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass)) + .ForMember(dest => dest.CustomerNotifyByEmail, + opt => opt.MapFrom(src => src.Customer == null || src.Customer.NotifyByEmail)); // JobItem mappings CreateMap() diff --git a/src/PowderCoating.Application/Services/FileService.cs b/src/PowderCoating.Application/Services/FileService.cs index 23c8de4..bd4686c 100644 --- a/src/PowderCoating.Application/Services/FileService.cs +++ b/src/PowderCoating.Application/Services/FileService.cs @@ -14,6 +14,7 @@ namespace PowderCoating.Application.Services; /// public class FileService : IFileService { + private const string UploadsRootFolder = "uploads"; private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; @@ -31,7 +32,9 @@ public class FileService : IFileService /// Validation order: null/empty check, size limit, then extension allowlist. The original file /// name is sanitised with to strip any directory components before /// prepending the GUID prefix, preventing path traversal if the browser supplies a name with - /// slashes. Returns a relative path (from wwwroot) suitable for storing in the database. + /// slashes. The target subfolder is resolved and confined under wwwroot/uploads/ before + /// any file system access occurs. Returns a relative path (from wwwroot) suitable for + /// storing in the database. /// public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveFileAsync( IFormFile file, @@ -65,7 +68,11 @@ public class FileService : IFileService // Create upload directory if it doesn't exist // NOTE: WebRootPath is read-only on Azure Linux App Service; this service is legacy // and should only be called for on-premises deployments. New uploads use Azure Blob. - var uploadPath = Path.Combine(_environment.WebRootPath, "uploads", subfolder); + if (!TryResolveUploadSubfolder(subfolder, out var uploadPath, out var relativeSubfolder, out var subfolderError)) + { + return (false, string.Empty, subfolderError); + } + if (!Directory.Exists(uploadPath)) { try @@ -93,7 +100,7 @@ public class FileService : IFileService } // Return relative path from wwwroot - var relativePath = Path.Combine("uploads", subfolder, uniqueFileName).Replace("\\", "/"); + var relativePath = Path.Combine(UploadsRootFolder, relativeSubfolder, uniqueFileName).Replace("\\", "/"); _logger.LogInformation("File saved successfully: {FilePath}", relativePath); return (true, relativePath, string.Empty); @@ -108,8 +115,8 @@ public class FileService : IFileService /// /// Deletes a file given its relative path from wwwroot. /// Returns success if the file does not exist (idempotent) so that callers do not need to check - /// existence before calling. The relative path is converted to an absolute path with - /// rather than string concatenation to prevent directory traversal. + /// existence before calling. The relative path is normalized and must remain under + /// wwwroot/uploads/; paths outside that root are rejected. /// public async Task<(bool Success, string ErrorMessage)> DeleteFileAsync(string filePath) { @@ -120,7 +127,10 @@ public class FileService : IFileService return (false, "File path is required."); } - var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar)); + if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError)) + { + return (false, pathError); + } if (!File.Exists(fullPath)) { @@ -142,8 +152,8 @@ public class FileService : IFileService /// /// Reads a file from disk and returns its raw bytes along with a derived MIME content type. - /// Intended for serving files that are stored outside wwwroot (or otherwise not directly - /// accessible via the static-files middleware) so controllers can stream them as file responses. + /// Intended for serving files that are stored under the legacy wwwroot/uploads/ path but + /// are otherwise not directly exposed through the static-files middleware. /// public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetFileAsync(string filePath) { @@ -154,7 +164,10 @@ public class FileService : IFileService return (false, Array.Empty(), string.Empty, "File path is required."); } - var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar)); + if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError)) + { + return (false, Array.Empty(), string.Empty, pathError); + } if (!File.Exists(fullPath)) { @@ -175,7 +188,7 @@ public class FileService : IFileService } /// - /// Checks whether a file exists at the given wwwroot-relative path without reading it. + /// Checks whether a file exists at the given wwwroot/uploads/-relative path without reading it. /// Used by views and controllers to conditionally show download links only when the file is present. /// public bool FileExists(string filePath) @@ -185,7 +198,11 @@ public class FileService : IFileService return false; } - var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar)); + if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out _)) + { + return false; + } + return File.Exists(fullPath); } @@ -212,4 +229,96 @@ public class FileService : IFileService _ => "application/octet-stream" }; } + + private bool TryResolveUploadSubfolder( + string subfolder, + out string uploadPath, + out string relativeSubfolder, + out string errorMessage) + { + uploadPath = string.Empty; + relativeSubfolder = string.Empty; + errorMessage = string.Empty; + + if (string.IsNullOrWhiteSpace(subfolder)) + { + errorMessage = "Upload subfolder is required."; + return false; + } + + if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage)) + { + return false; + } + + var normalizedSubfolder = subfolder.Replace('\\', '/').Trim('/'); + var resolvedPath = Path.GetFullPath( + Path.Combine(uploadsRoot, normalizedSubfolder.Replace('/', Path.DirectorySeparatorChar))); + + if (!IsWithinDirectory(resolvedPath, uploadsRoot)) + { + errorMessage = "Invalid upload subfolder."; + _logger.LogWarning("Rejected upload subfolder outside uploads root: {Subfolder}", subfolder); + return false; + } + + relativeSubfolder = Path.GetRelativePath(uploadsRoot, resolvedPath).Replace("\\", "/"); + uploadPath = resolvedPath; + return true; + } + + private bool TryResolveLegacyUploadPath(string filePath, out string fullPath, out string errorMessage) + { + fullPath = string.Empty; + errorMessage = string.Empty; + + if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage)) + { + return false; + } + + var normalizedRelativePath = filePath.Replace('\\', '/').TrimStart('/'); + if (!normalizedRelativePath.StartsWith($"{UploadsRootFolder}/", StringComparison.OrdinalIgnoreCase)) + { + errorMessage = "Invalid file path."; + _logger.LogWarning("Rejected legacy file path outside uploads root: {FilePath}", filePath); + return false; + } + + var resolvedPath = Path.GetFullPath( + Path.Combine(_environment.WebRootPath, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar))); + + if (!IsWithinDirectory(resolvedPath, uploadsRoot)) + { + errorMessage = "Invalid file path."; + _logger.LogWarning("Rejected path traversal attempt for legacy file path: {FilePath}", filePath); + return false; + } + + fullPath = resolvedPath; + return true; + } + + private bool TryGetUploadsRootPath(out string uploadsRoot, out string errorMessage) + { + uploadsRoot = string.Empty; + errorMessage = string.Empty; + + if (string.IsNullOrWhiteSpace(_environment.WebRootPath)) + { + errorMessage = "File storage is not available in this environment."; + _logger.LogWarning("WebRootPath is not configured for the legacy file service."); + return false; + } + + uploadsRoot = Path.GetFullPath(Path.Combine(_environment.WebRootPath, UploadsRootFolder)); + return true; + } + + private static bool IsWithinDirectory(string candidatePath, string rootPath) + { + var normalizedRoot = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return candidatePath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/PowderCoating.Core/Entities/CompanyPreferences.cs b/src/PowderCoating.Core/Entities/CompanyPreferences.cs index be09d44..c6fd24a 100644 --- a/src/PowderCoating.Core/Entities/CompanyPreferences.cs +++ b/src/PowderCoating.Core/Entities/CompanyPreferences.cs @@ -86,6 +86,22 @@ public class CompanyPreferences : BaseEntity /// JSON blob persisting QB Migration Wizard step state across sessions. public string? QbMigrationStateJson { get; set; } + // Guided activation / first-workflow onboarding + /// Selected first-workflow path: quote_first or job_first. Null until chosen. + public string? OnboardingPath { get; set; } + /// True once the company completes its first guided real workflow. + public bool FirstWorkflowCompleted { get; set; } = false; + /// UTC timestamp of when the first guided workflow was completed. + public DateTime? FirstWorkflowCompletedAt { get; set; } + /// UTC timestamp of the company's first quote creation. + public DateTime? FirstQuoteCreatedAt { get; set; } + /// UTC timestamp of the company's first job creation. + public DateTime? FirstJobCreatedAt { get; set; } + /// UTC timestamp of the company's first invoice creation. + public DateTime? FirstInvoiceCreatedAt { get; set; } + /// UTC timestamp of when the company dismissed guided activation without completing it. + public DateTime? GuidedActivationDismissedAt { get; set; } + // Navigation public virtual Company Company { get; set; } = null!; } diff --git a/src/PowderCoating.Core/Enums/NotificationEnums.cs b/src/PowderCoating.Core/Enums/NotificationEnums.cs index 51e9384..50435d6 100644 --- a/src/PowderCoating.Core/Enums/NotificationEnums.cs +++ b/src/PowderCoating.Core/Enums/NotificationEnums.cs @@ -17,5 +17,6 @@ public enum NotificationType SubscriptionExpiryReminder = 10, SubscriptionExpired = 11, SmsInboundStop = 12, - SmsInboundHelp = 13 + SmsInboundHelp = 13, + AdminEmail = 14 } 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 d0070fb..1e5df77 100644 --- a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs +++ b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs @@ -1,4 +1,5 @@ using PowderCoating.Core.Entities; +using PowderCoating.Core.Interfaces.Repositories; namespace PowderCoating.Core.Interfaces; @@ -13,29 +14,31 @@ public interface IUnitOfWork : IDisposable IRepository AiItemPredictions { get; } // Powder Insights - IRepository PowderUsageLogs { get; } + IPowderUsageLogRepository PowderUsageLogs { get; } - // Core entities - IRepository Customers { get; } - IRepository Jobs { 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 Quotes { get; } + IRepository JobPrepServices { get; } + IQuoteRepository Quotes { get; } IRepository QuotePhotos { get; } IRepository QuoteItems { get; } IRepository QuoteItemCoats { get; } 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; } @@ -69,38 +72,46 @@ public interface IUnitOfWork : IDisposable IRepository OvenBatches { get; } IRepository OvenBatchItems { get; } - // Invoices, Payments & Deposits - IRepository Invoices { get; } + // Invoices, Payments & Deposits — typed repository for complex include chains + IInvoiceRepository Invoices { get; } IRepository InvoiceItems { get; } IRepository Payments { get; } IRepository Deposits { get; } - // Purchase Orders - IRepository PurchaseOrders { get; } + // Purchase Orders — typed repository for paged/filtered list and detail load + IPurchaseOrderRepository PurchaseOrders { get; } IRepository PurchaseOrderItems { get; } - // Expense Tracking / Accounts Payable + // Expense Tracking / Accounts Payable — typed repository for Bills IRepository Accounts { get; } - IRepository Bills { get; } + IBillRepository Bills { get; } IRepository BillLineItems { get; } IRepository BillPayments { get; } IRepository Expenses { get; } - // Notifications - IRepository NotificationLogs { get; } + // Notifications — typed repository for IgnoreQueryFilters-based history lookups + INotificationLogRepository NotificationLogs { get; } IRepository NotificationTemplates { get; } // Subscription 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 new file mode 100644 index 0000000..3ebc7df --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs @@ -0,0 +1,49 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// +/// Typed repository for that adds domain-specific queries on top of +/// the generic CRUD interface. +/// +public interface IBillRepository : IRepository +{ + /// + /// Loads a single bill with the full include chain required by the Details view: Vendor, + /// APAccount, LineItems (filtered to non-deleted) with Account and Job navigations, and + /// Payments (filtered to non-deleted) with BankAccount. Returns null if not found. + /// + Task LoadForViewAsync(int id); + + /// + /// Loads a single bill with only its line items for the Edit form. Excludes payment + /// navigations since those are read-only after the bill is opened. + /// + Task LoadForEditAsync(int id); + + /// + /// Returns all bills for the Index/AP ledger view filtered by status and/or search term. + /// Includes Vendor so the list row can display vendor name without a second round trip. + /// LineItems are included for the search-in-description condition only. + /// + Task> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount); + + /// + /// Returns the last bill number with the given prefix (including soft-deleted records) for + /// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted. + /// + Task GetLastBillNumberAsync(string prefix); + + /// + /// Returns the last payment number with the given prefix (including soft-deleted records) + /// 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/ICustomerRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/ICustomerRepository.cs new file mode 100644 index 0000000..dc5c1b2 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/ICustomerRepository.cs @@ -0,0 +1,22 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// +/// Typed repository for that adds domain-specific queries on top of +/// the generic CRUD interface. +/// +public interface ICustomerRepository : IRepository +{ + /// + /// Loads a single customer with the navigations needed by the Details view: PricingTier, + /// and recent CustomerNotes ordered newest-first. Returns null if not found or soft-deleted. + /// + Task LoadForDetailsAsync(int id); + + /// + /// Finds a customer by email address within the current tenant. Used for duplicate-email + /// validation on create and edit. Returns null if no match is found. + /// + Task FindByEmailAsync(string email); +} 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/IInvoiceRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IInvoiceRepository.cs new file mode 100644 index 0000000..f82407a --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/IInvoiceRepository.cs @@ -0,0 +1,52 @@ +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// +/// Typed repository for that adds domain-specific queries on top of +/// the generic CRUD interface. +/// +public interface IInvoiceRepository : IRepository +{ + /// + /// Loads a single invoice with the full eight-table include chain required by the Details + /// view and PDF generation: Customer, Job, PreparedBy, SalesTaxAccount, InvoiceItems with + /// RevenueAccount and GeneratedGiftCertificate, Payments with RecordedBy and DepositAccount, + /// Refunds with IssuedBy, CreditApplications with CreditMemo, and GiftCertificateRedemptions. + /// Filtered includes exclude soft-deleted children. Returns null if not found or soft-deleted. + /// + Task LoadForViewAsync(int id); + + /// + /// Returns the invoice linked to a job, or null if none exists. Pass + /// = true to also surface soft-deleted invoices (used by + /// the 1:1 uniqueness guard that prevents duplicate invoices for the same job). + /// + Task GetForJobAsync(int jobId, bool includeDeleted = false); + + /// + /// Looks up an invoice by its online-payment token. Ignores query filters so the payment + /// portal can load the invoice even when the anonymous request has no tenant context. + /// Returns null if the token does not match any invoice. + /// + Task GetByPaymentTokenAsync(string token); + + /// + /// Returns the last invoice number that starts with for the given + /// company (including soft-deleted invoices) for sequential number generation. + /// + Task GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix); + + /// + /// Returns all non-deleted invoices that have at least one online payment for the given company + /// and date window, with Customer navigation loaded. Used by the Online Payments reconciliation view. + /// + Task> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to); + + /// + /// Returns all non-deleted CreditDebitCard refunds for the given company and date window, + /// with Invoice→Customer navigation loaded. Used by the Online Payments reconciliation view. + /// + Task> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to); +} 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 new file mode 100644 index 0000000..4401b74 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs @@ -0,0 +1,88 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// +/// Typed repository for that extends the generic CRUD interface with +/// domain-specific queries that require multi-level include chains the generic +/// cannot express. +/// +public interface IJobRepository : IRepository +{ + /// + /// Loads all active jobs with the minimal set of navigations needed to render the Kanban + /// board columns (Customer name, status, priority, assigned user, due date). Uses + /// AsNoTracking for read performance. + /// + Task> GetBoardJobsAsync(); + + /// + /// Loads a single job with the full include chain required by the Details view: Customer, + /// JobStatus, JobPriority, AssignedUser, Quote, OvenCost, OriginalJob, IntakeCheckedBy, + /// and all JobItems with their Coats (InventoryItem + Vendor) and PrepServices. + /// Also loads JobPrepServices (job-level prep) separately. Returns null if not found. + /// + Task LoadForDetailsAsync(int id); + + /// + /// Loads a single job with the include chain required by the Edit form: same as + /// but without the read-only audit navigations, and + /// with tracking enabled so changes can be saved. + /// + Task LoadForEditAsync(int id); + + /// + /// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump). + /// Includes only JobStatus. Returns null if not found or soft-deleted. + /// + Task LoadForStatusChangeAsync(int id); + + /// + /// Returns the change history for a job, ordered newest-first, with ChangedBy navigation + /// loaded. Used by the Details view changelog tab. + /// + Task> GetChangeHistoryAsync(int jobId); + + /// + /// Returns the last job number that starts with for the given + /// company (including soft-deleted jobs) for sequential number generation. + /// + Task GetLastJobNumberByPrefixAsync(int companyId, string prefix); + + /// + /// Looks up a job created from that may be incomplete (items not + /// saved). Ignores query filters so it catches soft-deleted leftover rows from a previous + /// failed conversion attempt. Used for orphan cleanup before retrying conversion. + /// + Task GetOrphanedConversionJobAsync(int quoteId, int companyId); + + /// + /// Loads all jobs scheduled for that are not in a terminal status, + /// with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats) navigations. + /// Optionally filtered to a single worker when is supplied. + /// Used by the ShopDisplay (TV board) action. + /// + Task> GetScheduledJobsForDateAsync(DateTime date, string? userId = null); + + /// + /// Loads all active (non-terminal, non-hold, non-cancelled) jobs for a company's shop mobile + /// view, with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats). + /// Optionally filtered to a single worker when is supplied. + /// + Task> GetActiveJobsForMobileAsync(int companyId, string? workerId = null); + + /// + /// Loads a single job with the navigations required by the costing breakdown endpoint: + /// OvenCost, Invoice, JobItems (with Coats), and TimeEntries (with Worker). + /// Scoped to as an extra safety check. + /// 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 new file mode 100644 index 0000000..ad4421b --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs @@ -0,0 +1,46 @@ +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// +/// Typed repository for that adds IgnoreQueryFilters-based lookups +/// by entity FK (InvoiceId, QuoteId, JobId) on top of the generic CRUD interface. +/// All methods bypass soft-delete and tenant filters so notification history is always visible +/// regardless of whether the linked entity has been soft-deleted. +/// +public interface INotificationLogRepository : IRepository +{ + /// Returns the most recent notification log entry for the given invoice, or null. + Task GetLatestForInvoiceAsync(int invoiceId); + + /// Returns all notification log entries for the given invoice, newest-first. + Task> GetAllForInvoiceAsync(int invoiceId); + + /// Returns the most recent notification log entry for the given quote, or null. + Task GetLatestForQuoteAsync(int quoteId); + + /// Returns all notification log entries for the given quote, newest-first. + Task> GetAllForQuoteAsync(int quoteId); + + /// Returns the most recent notification log entry for the given job, or null. + Task GetLatestForJobAsync(int jobId); + + /// 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/Repositories/IPurchaseOrderRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IPurchaseOrderRepository.cs new file mode 100644 index 0000000..016782b --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/IPurchaseOrderRepository.cs @@ -0,0 +1,44 @@ +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// Lightweight projection used by the Index KPI cards — avoids loading full entities for aggregate stats. +public record PurchaseOrderStats(int TotalCount, int OpenCount, decimal CommittedValue, int OverdueCount); + +/// +/// Typed repository for that adds domain-specific queries on top of +/// the generic CRUD interface. +/// +public interface IPurchaseOrderRepository : IRepository +{ + /// + /// Loads a single purchase order with the full include chain required by the Details view: + /// Vendor, Bill, and Items (filtered to non-deleted) with InventoryItem navigation. + /// Returns null if not found or not owned by the current tenant. + /// + Task LoadForViewAsync(int id, int companyId); + + /// + /// Returns KPI aggregate stats for the Index view using a server-side projection so only three + /// columns are fetched rather than full entities. + /// + Task GetStatsAsync(int companyId); + + /// + /// Returns a paged, filtered, and sorted list of purchase orders for the Index view. + /// All filter parameters are optional — passing null/empty applies no restriction for + /// that dimension. + /// + Task<(List Items, int TotalCount)> GetPagedAsync( + int companyId, + int pageNumber, + int pageSize, + PurchaseOrderStatus? statusFilter = null, + int? vendorId = null, + DateTime? dateFrom = null, + DateTime? dateTo = null, + string? searchTerm = null, + string? sortColumn = null, + string? sortDirection = null); +} diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IQuoteRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IQuoteRepository.cs new file mode 100644 index 0000000..c42cb19 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Repositories/IQuoteRepository.cs @@ -0,0 +1,53 @@ +using PowderCoating.Core.Entities; + +namespace PowderCoating.Core.Interfaces.Repositories; + +/// Aggregate counts and totals for the Quotes Index stat cards. +public record QuoteIndexStats(int OpenCount, int ApprovedConvertedCount, decimal TotalValue); + +/// +/// Typed repository for that adds domain-specific queries on top of +/// the generic CRUD interface. +/// +public interface IQuoteRepository : IRepository +{ + /// + /// Loads a single quote with the full include chain required by the Details view: Customer, + /// PreparedBy, QuoteStatus, OvenCost, QuoteItems with Coats (InventoryItem + Vendor), + /// CatalogItem, and PrepServices; plus QuotePrepServices (quote-level prep). + /// Returns null if not found or soft-deleted. + /// + Task LoadForDetailsAsync(int id); + + /// + /// Loads a single quote by its customer-facing approval token. Ignores global query filters + /// so the unauthenticated approval portal can resolve any tenant's quote by token alone. + /// Includes Customer navigation. Returns null if the token does not match any live quote. + /// + Task GetByApprovalTokenAsync(string token); + + /// + /// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded. + /// + Task> GetChangeHistoryAsync(int quoteId); + + /// + /// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the + /// current company by the global query filter. Pass status ID sets (derived from QuoteStatusLookup) + /// to classify open vs. approved/converted quotes. + /// + Task GetIndexStatsAsync(List openStatusIds, List approvedConvertedStatusIds); + + /// + /// Loads quote items with their coat passes (InventoryItem + Vendor) and prep services for + /// PDF generation and quote→job conversion. Cheaper than + /// because it skips the parent-quote navigations that callers already have. + /// + Task> GetItemsWithCoatsAsync(int quoteId); + + /// + /// Returns the last quote number that starts with for the given + /// company (including soft-deleted quotes) for sequential number generation. + /// + Task GetLastQuoteNumberByPrefixAsync(int companyId, string prefix); +} 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.Core/Interfaces/Services/IFinancialReportService.cs b/src/PowderCoating.Core/Interfaces/Services/IFinancialReportService.cs new file mode 100644 index 0000000..c358076 --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Services/IFinancialReportService.cs @@ -0,0 +1,2 @@ +// Moved to PowderCoating.Application.Interfaces.IFinancialReportService — Application layer owns DTO-returning service interfaces. +namespace PowderCoating.Core.Interfaces.Services; diff --git a/src/PowderCoating.Core/Interfaces/Services/IOperationalReportService.cs b/src/PowderCoating.Core/Interfaces/Services/IOperationalReportService.cs new file mode 100644 index 0000000..906c5cc --- /dev/null +++ b/src/PowderCoating.Core/Interfaces/Services/IOperationalReportService.cs @@ -0,0 +1,2 @@ +// Moved to PowderCoating.Application.Interfaces.IOperationalReportService — Application layer owns DTO-returning service interfaces. +namespace PowderCoating.Core.Interfaces.Services; diff --git a/src/PowderCoating.Infrastructure/Migrations/20260428164026_AddGuidedActivationFields.Designer.cs b/src/PowderCoating.Infrastructure/Migrations/20260428164026_AddGuidedActivationFields.Designer.cs new file mode 100644 index 0000000..ec0af8e --- /dev/null +++ b/src/PowderCoating.Infrastructure/Migrations/20260428164026_AddGuidedActivationFields.Designer.cs @@ -0,0 +1,9325 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PowderCoating.Infrastructure.Data; + +#nullable disable + +namespace PowderCoating.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260428164026_AddGuidedActivationFields")] + partial class AddGuidedActivationFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AccountSubType") + .HasColumnType("int"); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CurrentBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsSystem") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OpeningBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("OpeningBalanceDate") + .HasColumnType("datetime2"); + + b.Property("ParentAccountId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ParentAccountId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.AiItemPrediction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AiTags") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("Confidence") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationRounds") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PredictedComplexity") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PredictedMinutes") + .HasColumnType("int"); + + b.Property("PredictedSurfaceAreaSqFt") + .HasColumnType("decimal(18,2)"); + + b.Property("PredictedUnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Reasoning") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("UserOverrodeEstimate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("AiItemPredictions"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.AiUsageLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CalledAt") + .HasColumnType("datetime2"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("Feature") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InputLength") + .HasColumnType("int"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId", "CalledAt") + .HasDatabaseName("IX_AiUsageLogs_CompanyId_CalledAt"); + + b.ToTable("AiUsageLogs"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Announcement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedByUserName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDismissible") + .HasColumnType("bit"); + + b.Property("Message") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartsAt") + .HasColumnType("datetime2"); + + b.Property("Target") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TargetCompanyId") + .HasColumnType("int"); + + b.Property("TargetPlan") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("Announcements"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.AnnouncementDismissal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AnnouncementId") + .HasColumnType("int"); + + b.Property("DismissedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("AnnouncementId", "UserId") + .IsUnique(); + + b.ToTable("AnnouncementDismissals"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("BanReason") + .HasColumnType("nvarchar(max)"); + + b.Property("BannedAt") + .HasColumnType("datetime2"); + + b.Property("BannedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("CanApproveQuotes") + .HasColumnType("bit"); + + b.Property("CanCreateQuotes") + .HasColumnType("bit"); + + b.Property("CanManageCalendar") + .HasColumnType("bit"); + + b.Property("CanManageCustomers") + .HasColumnType("bit"); + + b.Property("CanManageEquipment") + .HasColumnType("bit"); + + b.Property("CanManageInventory") + .HasColumnType("bit"); + + b.Property("CanManageInvoices") + .HasColumnType("bit"); + + b.Property("CanManageJobs") + .HasColumnType("bit"); + + b.Property("CanManageMaintenance") + .HasColumnType("bit"); + + b.Property("CanManageProducts") + .HasColumnType("bit"); + + b.Property("CanManageVendors") + .HasColumnType("bit"); + + b.Property("CanViewCalendar") + .HasColumnType("bit"); + + b.Property("CanViewProducts") + .HasColumnType("bit"); + + b.Property("CanViewReports") + .HasColumnType("bit"); + + b.Property("CanViewShopFloor") + .HasColumnType("bit"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyRole") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DashboardLayout") + .HasColumnType("int"); + + b.Property("DateFormat") + .HasColumnType("nvarchar(max)"); + + b.Property("Department") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsBanned") + .HasColumnType("bit"); + + b.Property("LastLoginDate") + .HasColumnType("datetime2"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PasskeyPromptDismissed") + .HasColumnType("bit"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("Position") + .HasColumnType("nvarchar(max)"); + + b.Property("ProfilePictureFilePath") + .HasColumnType("nvarchar(max)"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("SidebarColor") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TerminationDate") + .HasColumnType("datetime2"); + + b.Property("Theme") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Appointment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ActualEndTime") + .HasColumnType("datetime2"); + + b.Property("ActualStartTime") + .HasColumnType("datetime2"); + + b.Property("AppointmentNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AppointmentStatusId") + .HasColumnType("int"); + + b.Property("AppointmentTypeId") + .HasColumnType("int"); + + b.Property("AssignedUserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsAllDay") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsReminderEnabled") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutesBefore") + .HasColumnType("int"); + + b.Property("ScheduledEndTime") + .HasColumnType("datetime2"); + + b.Property("ScheduledStartTime") + .HasColumnType("datetime2"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AppointmentStatusId"); + + b.HasIndex("AppointmentTypeId"); + + b.HasIndex("AssignedUserId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("JobId"); + + b.HasIndex("ScheduledStartTime"); + + b.HasIndex("CompanyId", "AppointmentStatusId") + .HasDatabaseName("IX_Appointments_CompanyId_AppointmentStatusId"); + + b.HasIndex("CompanyId", "ScheduledStartTime") + .HasDatabaseName("IX_Appointments_CompanyId_ScheduledStartTime"); + + b.ToTable("Appointments"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.AppointmentStatusLookup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ColorClass") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IconClass") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsSystemDefined") + .HasColumnType("bit"); + + b.Property("IsTerminalStatus") + .HasColumnType("bit"); + + b.Property("StatusCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("AppointmentStatusLookups"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.AppointmentTypeLookup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ColorClass") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IconClass") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsSystemDefined") + .HasColumnType("bit"); + + b.Property("RequiresJobLink") + .HasColumnType("bit"); + + b.Property("TypeCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("AppointmentTypeLookups"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityDescription") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .HasColumnType("nvarchar(450)"); + + b.Property("EntityType") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("IpAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValues") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValues") + .HasColumnType("nvarchar(max)"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId", "Timestamp"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.BannedIp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BannedAt") + .HasColumnType("datetime2"); + + b.Property("BannedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Reason") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("BannedIps"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Bill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("APAccountId") + .HasColumnType("int"); + + b.Property("AmountPaid") + .HasColumnType("decimal(18,2)"); + + b.Property("BillDate") + .HasColumnType("datetime2"); + + b.Property("BillNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Memo") + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptFilePath") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("Terms") + .HasColumnType("nvarchar(max)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorInvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("APAccountId"); + + b.HasIndex("DueDate"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("CompanyId", "Status"); + + b.ToTable("Bills"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.BillLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("BillId") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("BillId"); + + b.HasIndex("JobId"); + + b.ToTable("BillLineItems"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.BillPayment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("BankAccountId") + .HasColumnType("int"); + + b.Property("BillId") + .HasColumnType("int"); + + b.Property("CheckNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Memo") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentDate") + .HasColumnType("datetime2"); + + b.Property("PaymentMethod") + .HasColumnType("int"); + + b.Property("PaymentNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BankAccountId"); + + b.HasIndex("BillId"); + + b.HasIndex("VendorId"); + + b.ToTable("BillPayments"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.BugReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("ResolutionNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetime2"); + + b.Property("ResolvedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedByUserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SubmittedByUserName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("BugReports"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.BugReportAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BlobPath") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BugReportId") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BugReportId"); + + b.ToTable("BugReportAttachments"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CatalogCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsMerchandise") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ParentCategoryId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("ParentCategoryId"); + + b.ToTable("CatalogCategories"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CatalogItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApproximateArea") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CogsAccountId") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultEstimatedMinutes") + .HasColumnType("int"); + + b.Property("DefaultPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("DefaultRequiresMasking") + .HasColumnType("bit"); + + b.Property("DefaultRequiresSandblasting") + .HasColumnType("bit"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("ImagePath") + .HasColumnType("nvarchar(max)"); + + b.Property("InventoryItemId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsMerchandise") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RevenueAccountId") + .HasColumnType("int"); + + b.Property("SKU") + .HasColumnType("nvarchar(max)"); + + b.Property("ThumbnailPath") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("CogsAccountId"); + + b.HasIndex("CompanyId"); + + b.HasIndex("InventoryItemId"); + + b.HasIndex("RevenueAccountId"); + + b.ToTable("CatalogItems"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CatalogPriceCheckReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemsChecked") + .HasColumnType("int"); + + b.Property("OperatingCostsSummary") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ResultsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RunAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("CatalogPriceCheckReports"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountingOverride") + .HasColumnType("bit"); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AiCatalogPriceCheckEnabled") + .HasColumnType("bit"); + + b.Property("AiInventoryAssistEnabled") + .HasColumnType("bit"); + + b.Property("AiPhotoQuotesEnabled") + .HasColumnType("bit"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyCode") + .HasColumnType("nvarchar(450)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsAnnualBilling") + .HasColumnType("bit"); + + b.Property("IsComped") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LogoContentType") + .HasColumnType("nvarchar(max)"); + + b.Property("LogoData") + .HasColumnType("varbinary(max)"); + + b.Property("LogoFilePath") + .HasColumnType("nvarchar(max)"); + + b.Property("MarketingEmailOptOut") + .HasColumnType("bit"); + + b.Property("MarketingUnsubscribeToken") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("MaxActiveJobsOverride") + .HasColumnType("int"); + + b.Property("MaxAiPhotoQuotesPerMonthOverride") + .HasColumnType("int"); + + b.Property("MaxCatalogItemsOverride") + .HasColumnType("int"); + + b.Property("MaxCustomersOverride") + .HasColumnType("int"); + + b.Property("MaxJobPhotosOverride") + .HasColumnType("int"); + + b.Property("MaxQuotePhotosOverride") + .HasColumnType("int"); + + b.Property("MaxQuotesOverride") + .HasColumnType("int"); + + b.Property("MaxUsersOverride") + .HasColumnType("int"); + + b.Property("OnlinePaymentSurchargeType") + .HasColumnType("int"); + + b.Property("OnlinePaymentSurchargeValue") + .HasColumnType("decimal(18,2)"); + + b.Property("OnlinePaymentsOverride") + .HasColumnType("bit"); + + b.Property("OnlineSurchargeAcknowledged") + .HasColumnType("bit"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryContactEmail") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryContactName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeAccountId") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeConnectStatus") + .HasColumnType("int"); + + b.Property("StripeCustomerId") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeSubscriptionId") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionEndDate") + .HasColumnType("datetime2"); + + b.Property("SubscriptionNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("SubscriptionPlan") + .HasColumnType("int"); + + b.Property("SubscriptionStartDate") + .HasColumnType("datetime2"); + + b.Property("SubscriptionStatus") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyCode") + .IsUnique() + .HasFilter("[CompanyCode] IS NOT NULL"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CompanyBlastSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BlastNozzleSize") + .HasColumnType("int"); + + b.Property("BlastRateSqFtPerHourOverride") + .HasColumnType("decimal(18,2)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompressorCfm") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDefault") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PrimarySubstrate") + .HasColumnType("int"); + + b.Property("SetupType") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("CompanyBlastSetups"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CompanyOperatingCosts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdditionalCoatLaborPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("AiContextProfile") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("BlastNozzleSize") + .HasColumnType("int"); + + b.Property("BlastRateSqFtPerHourOverride") + .HasColumnType("decimal(18,2)"); + + b.Property("BlastSetupType") + .HasColumnType("int"); + + b.Property("CoatingBoothCostPerHour") + .HasColumnType("decimal(18,2)"); + + b.Property("CoatingGunType") + .HasColumnType("int"); + + b.Property("CoatingRateSqFtPerHourOverride") + .HasColumnType("decimal(18,2)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("ComplexityComplexPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("ComplexityExtremePercent") + .HasColumnType("decimal(18,2)"); + + b.Property("ComplexityModeratePercent") + .HasColumnType("decimal(18,2)"); + + b.Property("ComplexitySimplePercent") + .HasColumnType("decimal(18,2)"); + + b.Property("CompressorCfm") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultOvenCycleMinutes") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("GeneralMarkupPercentage") + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MonthlyBillableHours") + .HasColumnType("int"); + + b.Property("MonthlyRent") + .HasColumnType("decimal(18,2)"); + + b.Property("MonthlyUtilities") + .HasColumnType("decimal(18,2)"); + + b.Property("OvenOperatingCostPerHour") + .HasColumnType("decimal(18,2)"); + + b.Property("PowderCoatingCostPerSqFt") + .HasColumnType("decimal(18,2)"); + + b.Property("PricingMode") + .HasColumnType("int"); + + b.Property("PrimaryBlastSubstrate") + .HasColumnType("int"); + + b.Property("RushChargeFixedAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("RushChargePercentage") + .HasColumnType("decimal(18,2)"); + + b.Property("RushChargeType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SandblasterCostPerHour") + .HasColumnType("decimal(18,2)"); + + b.Property("ShopCapabilityTier") + .HasColumnType("int"); + + b.Property("ShopMinimumCharge") + .HasColumnType("decimal(18,2)"); + + b.Property("ShopSuppliesRate") + .HasColumnType("decimal(18,2)"); + + b.Property("StandardLaborRate") + .HasColumnType("decimal(18,2)"); + + b.Property("TargetMarginPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId") + .IsUnique(); + + b.ToTable("CompanyOperatingCosts"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CompanyPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllowCustomerApproval") + .HasColumnType("bit"); + + b.Property("AutoArchiveJobsDays") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultCurrency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultDateFormat") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultJobPriority") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultPaymentTerms") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultQuoteValidityDays") + .HasColumnType("int"); + + b.Property("DefaultTimeFormat") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultTurnaroundDays") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedRecordRetentionDays") + .HasColumnType("int"); + + b.Property("DueDateWarningDays") + .HasColumnType("int"); + + b.Property("EmailFromAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("EmailFromName") + .HasColumnType("nvarchar(max)"); + + b.Property("EmailNotificationsEnabled") + .HasColumnType("bit"); + + b.Property("FirstInvoiceCreatedAt") + .HasColumnType("datetime2"); + + b.Property("FirstJobCreatedAt") + .HasColumnType("datetime2"); + + b.Property("FirstQuoteCreatedAt") + .HasColumnType("datetime2"); + + b.Property("FirstWorkflowCompleted") + .HasColumnType("bit"); + + b.Property("FirstWorkflowCompletedAt") + .HasColumnType("datetime2"); + + b.Property("GuidedActivationDismissedAt") + .HasColumnType("datetime2"); + + b.Property("InAccentColor") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InDefaultTerms") + .HasColumnType("nvarchar(max)"); + + b.Property("InFooterNote") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceNumberPrefix") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobNumberPrefix") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobRetentionYears") + .HasColumnType("int"); + + b.Property("LogRetentionDays") + .HasColumnType("int"); + + b.Property("MaintenanceAlertDays") + .HasColumnType("int"); + + b.Property("MigratingFromQuickBooks") + .HasColumnType("bit"); + + b.Property("NotifyOnJobStatusChange") + .HasColumnType("bit"); + + b.Property("NotifyOnNewJob") + .HasColumnType("bit"); + + b.Property("NotifyOnNewQuote") + .HasColumnType("bit"); + + b.Property("NotifyOnPaymentReceived") + .HasColumnType("bit"); + + b.Property("NotifyOnQuoteApproval") + .HasColumnType("bit"); + + b.Property("OnboardingPath") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentReminderDays") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentRemindersEnabled") + .HasColumnType("bit"); + + b.Property("QbMigrationStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("QtAccentColor") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("QtDefaultTerms") + .HasColumnType("nvarchar(max)"); + + b.Property("QtFooterNote") + .HasColumnType("nvarchar(max)"); + + b.Property("QuoteExpiryWarningDays") + .HasColumnType("int"); + + b.Property("QuoteNumberPrefix") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("QuoteRetentionYears") + .HasColumnType("int"); + + b.Property("RequireCustomerPO") + .HasColumnType("bit"); + + b.Property("SetupWizardCompleted") + .HasColumnType("bit"); + + b.Property("SetupWizardCompletedAt") + .HasColumnType("datetime2"); + + b.Property("SetupWizardCompletedByName") + .HasColumnType("nvarchar(max)"); + + b.Property("SetupWizardCompletedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("SetupWizardDoneSteps") + .HasColumnType("nvarchar(max)"); + + b.Property("SetupWizardSkippedSteps") + .HasColumnType("nvarchar(max)"); + + b.Property("SetupWizardStarted") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("UseMetricSystem") + .HasColumnType("bit"); + + b.Property("WoAccentColor") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WoTerms") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId") + .IsUnique(); + + b.ToTable("CompanyPreferences"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ContactSubmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdminNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsRead") + .HasColumnType("bit"); + + b.Property("Message") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReadAt") + .HasColumnType("datetime2"); + + b.Property("ReadByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("ReadByUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("SenderEmail") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SenderName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ContactSubmissions"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CreditMemo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("AmountApplied") + .HasColumnType("decimal(18,2)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ExpiryDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssueDate") + .HasColumnType("datetime2"); + + b.Property("IssuedById") + .HasColumnType("nvarchar(450)"); + + b.Property("MemoNumber") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalInvoiceId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReworkRecordId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("IssuedById"); + + b.HasIndex("OriginalInvoiceId"); + + b.HasIndex("ReworkRecordId"); + + b.HasIndex("CompanyId", "MemoNumber") + .IsUnique() + .HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber"); + + b.ToTable("CreditMemos"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CreditMemoApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AmountApplied") + .HasColumnType("decimal(18,2)"); + + b.Property("AppliedById") + .HasColumnType("nvarchar(450)"); + + b.Property("AppliedDate") + .HasColumnType("datetime2"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreditMemoId") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AppliedById"); + + b.HasIndex("CreditMemoId"); + + b.HasIndex("InvoiceId"); + + b.ToTable("CreditMemoApplications"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(450)"); + + b.Property("ContactFirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactLastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreditBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("CreditLimit") + .HasColumnType("decimal(18,2)"); + + b.Property("CurrentBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneralNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsCommercial") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsTaxExempt") + .HasColumnType("bit"); + + b.Property("LastContactDate") + .HasColumnType("datetime2"); + + b.Property("MobilePhone") + .HasColumnType("nvarchar(max)"); + + b.Property("NotifyByEmail") + .HasColumnType("bit"); + + b.Property("NotifyBySms") + .HasColumnType("bit"); + + b.Property("PaymentTerms") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PricingTierId") + .HasColumnType("int"); + + b.Property("SmsConsentMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SmsConsentedAt") + .HasColumnType("datetime2"); + + b.Property("SmsOptedOutAt") + .HasColumnType("datetime2"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TaxExemptCertificateContentType") + .HasColumnType("nvarchar(max)"); + + b.Property("TaxExemptCertificateData") + .HasColumnType("varbinary(max)"); + + b.Property("TaxExemptCertificateFileName") + .HasColumnType("nvarchar(max)"); + + b.Property("TaxId") + .HasColumnType("nvarchar(max)"); + + b.Property("UnsubscribeToken") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)") + .HasDefaultValueSql("REPLACE(NEWID(),'-','')"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("CompanyName"); + + b.HasIndex("PricingTierId"); + + b.HasIndex("UnsubscribeToken") + .IsUnique() + .HasDatabaseName("IX_Customers_UnsubscribeToken"); + + b.HasIndex("CompanyId", "Email") + .IsUnique() + .HasFilter("[Email] IS NOT NULL"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsImportant") + .HasColumnType("bit"); + + b.Property("Note") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "CreatedAt") + .HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt"); + + b.ToTable("CustomerNotes"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("TipText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DashboardTips"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("AppliedDate") + .HasColumnType("datetime2"); + + b.Property("AppliedToInvoiceId") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentMethod") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("ReceiptNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceivedDate") + .HasColumnType("datetime2"); + + b.Property("RecordedById") + .HasColumnType("nvarchar(450)"); + + b.Property("Reference") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AppliedToInvoiceId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("JobId"); + + b.HasIndex("QuoteId"); + + b.HasIndex("RecordedById"); + + b.ToTable("Deposits"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("EquipmentName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EquipmentNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("EquipmentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastMaintenanceDate") + .HasColumnType("datetime2"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ManualContentType") + .HasColumnType("nvarchar(max)"); + + b.Property("ManualFileName") + .HasColumnType("nvarchar(max)"); + + b.Property("ManualFilePath") + .HasColumnType("nvarchar(max)"); + + b.Property("ManualFileSize") + .HasColumnType("bigint"); + + b.Property("ManualUploadedDate") + .HasColumnType("datetime2"); + + b.Property("Manufacturer") + .HasColumnType("nvarchar(max)"); + + b.Property("MaxLoadSqFt") + .HasColumnType("decimal(18,2)"); + + b.Property("Model") + .HasColumnType("nvarchar(max)"); + + b.Property("NextScheduledMaintenance") + .HasColumnType("datetime2"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OvenCycleMinutes") + .HasColumnType("int"); + + b.Property("PurchaseDate") + .HasColumnType("datetime2"); + + b.Property("PurchasePrice") + .HasColumnType("decimal(18,2)"); + + b.Property("RecommendedMaintenanceIntervalDays") + .HasColumnType("int"); + + b.Property("SerialNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WarrantyExpiration") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("CompanyId", "Status") + .HasDatabaseName("IX_Equipment_CompanyId_Status"); + + b.ToTable("Equipment"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Expense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ExpenseAccountId") + .HasColumnType("int"); + + b.Property("ExpenseNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Memo") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentAccountId") + .HasColumnType("int"); + + b.Property("PaymentMethod") + .HasColumnType("int"); + + b.Property("ReceiptFilePath") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ExpenseAccountId"); + + b.HasIndex("JobId"); + + b.HasIndex("PaymentAccountId"); + + b.HasIndex("VendorId"); + + b.ToTable("Expenses"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CertificateCode") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ExpiryDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssueDate") + .HasColumnType("datetime2"); + + b.Property("IssuedById") + .HasColumnType("nvarchar(450)"); + + b.Property("IssuedReason") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PurchasePrice") + .HasColumnType("decimal(18,2)"); + + b.Property("PurchasingCustomerId") + .HasColumnType("int"); + + b.Property("RecipientCustomerId") + .HasColumnType("int"); + + b.Property("RecipientEmail") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RedeemedAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("SourceInvoiceItemId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("IssuedById"); + + b.HasIndex("PurchasingCustomerId"); + + b.HasIndex("RecipientCustomerId"); + + b.HasIndex("CompanyId", "CertificateCode") + .IsUnique(); + + b.ToTable("GiftCertificates"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificateRedemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AmountRedeemed") + .HasColumnType("decimal(18,2)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("GiftCertificateId") + .HasColumnType("int"); + + b.Property("InvoiceId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("RedeemedById") + .HasColumnType("nvarchar(450)"); + + b.Property("RedeemedDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("GiftCertificateId"); + + b.HasIndex("InvoiceId"); + + b.HasIndex("RedeemedById"); + + b.ToTable("GiftCertificateRedemptions"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InAppNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsRead") + .HasColumnType("bit"); + + b.Property("Link") + .HasColumnType("nvarchar(max)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NotificationType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("ReadAt") + .HasColumnType("datetime2"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("InvoiceId"); + + b.HasIndex("QuoteId"); + + b.ToTable("InAppNotifications"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InventoryCategoryLookup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryCode") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsCoating") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsSystemDefined") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("CompanyId", "CategoryCode") + .IsUnique(); + + b.ToTable("InventoryCategoryLookups"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageCost") + .HasColumnType("decimal(18,2)"); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CogsAccountId") + .HasColumnType("int"); + + b.Property("ColorCode") + .HasColumnType("nvarchar(max)"); + + b.Property("ColorFamilies") + .HasColumnType("nvarchar(max)"); + + b.Property("ColorName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CoverageSqFtPerLb") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CureTemperatureF") + .HasColumnType("decimal(18,2)"); + + b.Property("CureTimeMinutes") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DiscontinuedDate") + .HasColumnType("datetime2"); + + b.Property("Finish") + .HasColumnType("nvarchar(max)"); + + b.Property("HasSamplePanel") + .HasColumnType("bit"); + + b.Property("InventoryAccountId") + .HasColumnType("int"); + + b.Property("InventoryCategoryId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastPurchaseDate") + .HasColumnType("datetime2"); + + b.Property("LastPurchasePrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Manufacturer") + .HasColumnType("nvarchar(max)"); + + b.Property("ManufacturerPartNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("MaximumStock") + .HasColumnType("decimal(18,2)"); + + b.Property("MinimumStock") + .HasColumnType("decimal(18,2)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryVendorId") + .HasColumnType("int"); + + b.Property("QuantityOnHand") + .HasColumnType("decimal(18,2)"); + + b.Property("ReorderPoint") + .HasColumnType("decimal(18,2)"); + + b.Property("ReorderQuantity") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiresClearCoat") + .HasColumnType("bit"); + + b.Property("SKU") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("SpecPageUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("TransferEfficiency") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitCost") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitOfMeasure") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorPartNumber") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CogsAccountId"); + + b.HasIndex("CompanyId"); + + b.HasIndex("InventoryAccountId"); + + b.HasIndex("InventoryCategoryId"); + + b.HasIndex("IsActive"); + + b.HasIndex("PrimaryVendorId"); + + b.HasIndex("CompanyId", "IsActive"); + + b.HasIndex("CompanyId", "SKU") + .IsUnique() + .HasDatabaseName("IX_InventoryItems_CompanyId_SKU"); + + b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint") + .HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder"); + + b.ToTable("InventoryItems"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InventoryTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BalanceAfter") + .HasColumnType("decimal(18,2)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("InventoryItemId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PurchaseOrderId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("Reference") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCost") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionDate") + .HasColumnType("datetime2"); + + b.Property("TransactionType") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InventoryItemId"); + + b.HasIndex("JobId"); + + b.HasIndex("PurchaseOrderId"); + + b.HasIndex("TransactionType", "TransactionDate"); + + b.ToTable("InventoryTransactions"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AmountPaid") + .HasColumnType("decimal(18,2)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreditApplied") + .HasColumnType("decimal(18,2)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("CustomerPO") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DiscountAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalReference") + .HasColumnType("nvarchar(450)"); + + b.Property("GiftCertificateRedeemed") + .HasColumnType("decimal(18,2)"); + + b.Property("InternalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceDate") + .HasColumnType("datetime2"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OnlineAmountPaid") + .HasColumnType("decimal(18,2)"); + + b.Property("OnlinePaymentStatus") + .HasColumnType("int"); + + b.Property("OnlineSurchargeCollected") + .HasColumnType("decimal(18,2)"); + + b.Property("PaidDate") + .HasColumnType("datetime2"); + + b.Property("PaymentLinkExpiresAt") + .HasColumnType("datetime2"); + + b.Property("PaymentLinkToken") + .HasColumnType("nvarchar(max)"); + + b.Property("PreparedById") + .HasColumnType("nvarchar(450)"); + + b.Property("SalesTaxAccountId") + .HasColumnType("int"); + + b.Property("SentDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StripePaymentIntentId") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("Terms") + .HasColumnType("nvarchar(max)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("DueDate"); + + b.HasIndex("InvoiceDate"); + + b.HasIndex("JobId") + .IsUnique() + .HasFilter("[JobId] IS NOT NULL"); + + b.HasIndex("PreparedById"); + + b.HasIndex("SalesTaxAccountId"); + + b.HasIndex("Status"); + + b.HasIndex("CompanyId", "CustomerId") + .HasDatabaseName("IX_Invoices_CompanyId_CustomerId"); + + b.HasIndex("CompanyId", "DueDate") + .HasDatabaseName("IX_Invoices_CompanyId_DueDate"); + + b.HasIndex("CompanyId", "ExternalReference") + .HasDatabaseName("IX_Invoices_CompanyId_ExternalReference"); + + b.HasIndex("CompanyId", "InvoiceNumber") + .IsUnique() + .HasDatabaseName("IX_Invoices_CompanyId_InvoiceNumber"); + + b.HasIndex("CompanyId", "IsDeleted"); + + b.HasIndex("CompanyId", "JobId") + .IsUnique() + .HasDatabaseName("IX_Invoices_CompanyId_JobId") + .HasFilter("[JobId] IS NOT NULL"); + + b.HasIndex("CompanyId", "Status") + .HasDatabaseName("IX_Invoices_CompanyId_Status"); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InvoiceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CatalogItemId") + .HasColumnType("int"); + + b.Property("ColorName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("GcExpiryDate") + .HasColumnType("datetime2"); + + b.Property("GcRecipientEmail") + .HasColumnType("nvarchar(max)"); + + b.Property("GcRecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("GeneratedGiftCertificateId") + .HasColumnType("int"); + + b.Property("InvoiceId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsGiftCertificate") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("RevenueAccountId") + .HasColumnType("int"); + + b.Property("SourceJobItemId") + .HasColumnType("int"); + + b.Property("TotalPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CatalogItemId"); + + b.HasIndex("CompanyId"); + + b.HasIndex("GeneratedGiftCertificateId"); + + b.HasIndex("InvoiceId") + .HasDatabaseName("IX_InvoiceItems_InvoiceId"); + + b.HasIndex("RevenueAccountId"); + + b.HasIndex("SourceJobItemId"); + + b.ToTable("InvoiceItems"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ActualTimeSpentHours") + .HasColumnType("decimal(18,2)"); + + b.Property("AssignedUserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("CustomerPO") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DiscountReason") + .HasColumnType("nvarchar(max)"); + + b.Property("DiscountType") + .HasColumnType("int"); + + b.Property("DiscountValue") + .HasColumnType("decimal(18,2)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("FinalPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("IntakeCheckedByUserId") + .HasColumnType("nvarchar(450)"); + + b.Property("IntakeConditionNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IntakeDate") + .HasColumnType("datetime2"); + + b.Property("IntakePartCount") + .HasColumnType("int"); + + b.Property("InternalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsCustomerApproved") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsReworkJob") + .HasColumnType("bit"); + + b.Property("IsRushJob") + .HasColumnType("bit"); + + b.Property("JobNumber") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("JobPriorityId") + .HasColumnType("int"); + + b.Property("JobStatusId") + .HasColumnType("int"); + + b.Property("OriginalJobId") + .HasColumnType("int"); + + b.Property("OvenCostId") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("QuotedPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiresCustomerApproval") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ShopAccessCode") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWID()"); + + b.Property("ShopWorkerId") + .HasColumnType("int"); + + b.Property("SpecialInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("StartedDate") + .HasColumnType("datetime2"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignedUserId"); + + b.HasIndex("CompanyId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("DueDate"); + + b.HasIndex("IntakeCheckedByUserId"); + + b.HasIndex("JobPriorityId"); + + b.HasIndex("JobStatusId"); + + b.HasIndex("OriginalJobId"); + + b.HasIndex("OvenCostId"); + + b.HasIndex("QuoteId") + .IsUnique() + .HasFilter("[QuoteId] IS NOT NULL"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("ShopWorkerId"); + + b.HasIndex("CompanyId", "CustomerId") + .HasDatabaseName("IX_Jobs_CompanyId_CustomerId"); + + b.HasIndex("CompanyId", "DueDate") + .HasDatabaseName("IX_Jobs_CompanyId_DueDate"); + + b.HasIndex("CompanyId", "IsDeleted"); + + b.HasIndex("CompanyId", "JobNumber") + .IsUnique() + .HasDatabaseName("IX_Jobs_CompanyId_JobNumber"); + + b.HasIndex("CompanyId", "JobPriorityId") + .HasDatabaseName("IX_Jobs_CompanyId_JobPriorityId"); + + b.HasIndex("CompanyId", "JobStatusId") + .HasDatabaseName("IX_Jobs_CompanyId_JobStatusId"); + + b.HasIndex("CompanyId", "ScheduledDate") + .HasDatabaseName("IX_Jobs_CompanyId_ScheduledDate"); + + b.HasIndex("CompanyId", "ShopAccessCode") + .IsUnique() + .HasDatabaseName("IX_Jobs_CompanyId_ShopAccessCode"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobChangeHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChangeDescription") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ChangedAt") + .HasColumnType("datetime2"); + + b.Property("ChangedByUserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ChangedByUserId"); + + b.HasIndex("CompanyId"); + + b.HasIndex("JobId"); + + b.ToTable("JobChangeHistories"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobDailyPriority", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.ToTable("JobDailyPriorities"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AiPredictionId") + .HasColumnType("int"); + + b.Property("AiTags") + .HasColumnType("nvarchar(max)"); + + b.Property("CatalogItemId") + .HasColumnType("int"); + + b.Property("ColorCode") + .HasColumnType("nvarchar(max)"); + + b.Property("ColorName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("Complexity") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("Finish") + .HasColumnType("nvarchar(max)"); + + b.Property("IncludePrepCost") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsGenericItem") + .HasColumnType("bit"); + + b.Property("IsLaborItem") + .HasColumnType("bit"); + + b.Property("IsSalesItem") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("LaborCost") + .HasColumnType("decimal(18,2)"); + + b.Property("ManualUnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PowderCostOverride") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiresMasking") + .HasColumnType("bit"); + + b.Property("RequiresSandblasting") + .HasColumnType("bit"); + + b.Property("Sku") + .HasColumnType("nvarchar(max)"); + + b.Property("SurfaceArea") + .HasColumnType("decimal(18,2)"); + + b.Property("SurfaceAreaSqFt") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AiPredictionId"); + + b.HasIndex("CatalogItemId"); + + b.HasIndex("JobId") + .HasDatabaseName("IX_JobItems_JobId"); + + b.HasIndex("JobId", "IsDeleted") + .HasDatabaseName("IX_JobItems_JobId_IsDeleted"); + + b.ToTable("JobItems"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobItemCoat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ActualPowderUsedLbs") + .HasColumnType("decimal(18,2)"); + + b.Property("CoatName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ColorCode") + .HasColumnType("nvarchar(max)"); + + b.Property("ColorName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CoverageSqFtPerLb") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Finish") + .HasColumnType("nvarchar(max)"); + + b.Property("InventoryItemId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobItemId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PowderCostPerLb") + .HasColumnType("decimal(18,2)"); + + b.Property("PowderOrdered") + .HasColumnType("bit"); + + b.Property("PowderOrderedAt") + .HasColumnType("datetime2"); + + b.Property("PowderOrderedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("PowderReceived") + .HasColumnType("bit"); + + b.Property("PowderReceivedAt") + .HasColumnType("datetime2"); + + b.Property("PowderReceivedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("PowderReceivedLbs") + .HasColumnType("decimal(18,2)"); + + b.Property("PowderToOrder") + .HasColumnType("decimal(18,2)"); + + b.Property("Sequence") + .HasColumnType("int"); + + b.Property("TransferEfficiency") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("InventoryItemId"); + + b.HasIndex("JobItemId"); + + b.HasIndex("VendorId"); + + b.ToTable("JobItemCoats"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobItemPrepService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BlastSetupId") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobItemId") + .HasColumnType("int"); + + b.Property("PrepServiceId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BlastSetupId"); + + b.HasIndex("CompanyId") + .HasDatabaseName("IX_JobItemPrepServices_CompanyId"); + + b.HasIndex("JobItemId") + .HasDatabaseName("IX_JobItemPrepServices_JobItemId"); + + b.HasIndex("PrepServiceId") + .HasDatabaseName("IX_JobItemPrepServices_PrepServiceId"); + + b.ToTable("JobItemPrepServices"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsImportant") + .HasColumnType("bit"); + + b.Property("IsInternal") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Note") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId", "CreatedAt") + .HasDatabaseName("IX_JobNotes_JobId_CreatedAt"); + + b.ToTable("JobNotes"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Caption") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("IsAiAnalysisPhoto") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("PhotoType") + .HasColumnType("int"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("UploadedById") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UploadedDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("UploadedById"); + + b.HasIndex("JobId", "IsDeleted", "DisplayOrder") + .HasDatabaseName("IX_JobPhotos_JobId_IsDeleted_DisplayOrder"); + + b.ToTable("JobPhotos"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobPrepService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("PrepServiceId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("PrepServiceId"); + + b.ToTable("JobPrepServices"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobPriorityLookup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ColorClass") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IconClass") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsSystemDefined") + .HasColumnType("bit"); + + b.Property("PriorityCode") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("CompanyId", "PriorityCode") + .IsUnique(); + + b.ToTable("JobPriorityLookups"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobStatusHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChangedDate") + .HasColumnType("datetime2"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("FromStatusId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("ToStatusId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("FromStatusId"); + + b.HasIndex("JobId"); + + b.HasIndex("ToStatusId"); + + b.ToTable("JobStatusHistory"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobStatusLookup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ColorClass") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IconClass") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsSystemDefined") + .HasColumnType("bit"); + + b.Property("IsTerminalStatus") + .HasColumnType("bit"); + + b.Property("IsWorkInProgressStatus") + .HasColumnType("bit"); + + b.Property("StatusCode") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkflowCategory") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("CompanyId", "StatusCode") + .IsUnique(); + + b.ToTable("JobStatusLookups"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SpecialInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("UsageCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("JobTemplates"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CatalogItemId") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("Complexity") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("IncludePrepCost") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsGenericItem") + .HasColumnType("bit"); + + b.Property("IsLaborItem") + .HasColumnType("bit"); + + b.Property("JobTemplateId") + .HasColumnType("int"); + + b.Property("ManualUnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiresMasking") + .HasColumnType("bit"); + + b.Property("RequiresSandblasting") + .HasColumnType("bit"); + + b.Property("SurfaceAreaSqFt") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CatalogItemId"); + + b.HasIndex("JobTemplateId"); + + b.ToTable("JobTemplateItems"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTemplateItemCoat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CoatName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ColorCode") + .HasColumnType("nvarchar(max)"); + + b.Property("ColorName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CoverageSqFtPerLb") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Finish") + .HasColumnType("nvarchar(max)"); + + b.Property("InventoryItemId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTemplateItemId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PowderCostPerLb") + .HasColumnType("decimal(18,2)"); + + b.Property("Sequence") + .HasColumnType("int"); + + b.Property("TransferEfficiency") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("InventoryItemId"); + + b.HasIndex("JobTemplateItemId"); + + b.HasIndex("VendorId"); + + b.ToTable("JobTemplateItemCoats"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTemplateItemPrepService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTemplateItemId") + .HasColumnType("int"); + + b.Property("PrepServiceId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobTemplateItemId"); + + b.HasIndex("PrepServiceId"); + + b.ToTable("JobTemplateItemPrepServices"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("HoursWorked") + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("ShopWorkerId") + .HasColumnType("int"); + + b.Property("Stage") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("ShopWorkerId"); + + b.ToTable("JobTimeEntries"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssignedUserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DowntimeHours") + .HasColumnType("decimal(18,2)"); + + b.Property("EquipmentId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsRecurring") + .HasColumnType("bit"); + + b.Property("LaborCost") + .HasColumnType("decimal(18,2)"); + + b.Property("MaintenanceType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PartsCost") + .HasColumnType("decimal(18,2)"); + + b.Property("PartsReplaced") + .HasColumnType("nvarchar(max)"); + + b.Property("PerformedById") + .HasColumnType("nvarchar(450)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("RecurrenceEndDate") + .HasColumnType("datetime2"); + + b.Property("RecurrenceFrequency") + .HasColumnType("int"); + + b.Property("RecurrenceGroupId") + .HasColumnType("nvarchar(max)"); + + b.Property("RecurrenceParentId") + .HasColumnType("int"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ShopWorkerId") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TechnicianNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCost") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkPerformed") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignedUserId"); + + b.HasIndex("EquipmentId"); + + b.HasIndex("PerformedById"); + + b.HasIndex("RecurrenceParentId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("ShopWorkerId"); + + b.HasIndex("Status"); + + b.HasIndex("CompanyId", "ScheduledDate") + .HasDatabaseName("IX_MaintenanceRecords_CompanyId_ScheduledDate"); + + b.HasIndex("CompanyId", "Status") + .HasDatabaseName("IX_MaintenanceRecords_CompanyId_Status"); + + b.ToTable("MaintenanceRecords"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ManufacturerLookupPattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Domain") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ManufacturerName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("ProductUrlTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("SlugTransform") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ManufacturerLookupPatterns"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("Message") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NotificationType") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("Recipient") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Subject") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("InvoiceId"); + + b.HasIndex("JobId"); + + b.HasIndex("QuoteId"); + + b.HasIndex("CompanyId", "SentAt") + .HasDatabaseName("IX_NotificationLogs_CompanyId_SentAt"); + + b.HasIndex("CompanyId", "Status") + .HasDatabaseName("IX_NotificationLogs_CompanyId_Status"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.NotificationTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Channel") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("NotificationType") + .HasColumnType("int"); + + b.Property("Subject") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId", "NotificationType", "Channel") + .IsUnique() + .HasDatabaseName("IX_NotificationTemplates_Company_Type_Channel"); + + b.ToTable("NotificationTemplates"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.OvenBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ActualEndTime") + .HasColumnType("datetime2"); + + b.Property("ActualStartTime") + .HasColumnType("datetime2"); + + b.Property("AiReasoningJson") + .HasColumnType("nvarchar(max)"); + + b.Property("AiSuggested") + .HasColumnType("bit"); + + b.Property("BatchNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CureTemperatureF") + .HasColumnType("decimal(18,2)"); + + b.Property("CycleMinutes") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("EquipmentId") + .HasColumnType("int"); + + b.Property("EstimatedEndTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OvenCostId") + .HasColumnType("int"); + + b.Property("PrimaryColorCode") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryColorName") + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledStartTime") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalSurfaceAreaSqFt") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EquipmentId"); + + b.HasIndex("OvenCostId"); + + b.HasIndex("ScheduledDate", "Status"); + + b.ToTable("OvenBatches"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.OvenBatchItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CoatPassNumber") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("JobItemCoatId") + .HasColumnType("int"); + + b.Property("JobItemId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OvenBatchId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SurfaceAreaContribution") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("JobItemCoatId"); + + b.HasIndex("JobItemId"); + + b.HasIndex("OvenBatchId"); + + b.ToTable("OvenBatchItems"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.OvenCost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CostPerHour") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultCycleMinutes") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("MaxLoadSqFt") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("OvenCosts"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DepositAccountId") + .HasColumnType("int"); + + b.Property("InvoiceId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentDate") + .HasColumnType("datetime2"); + + b.Property("PaymentMethod") + .HasColumnType("int"); + + b.Property("RecordedById") + .HasColumnType("nvarchar(450)"); + + b.Property("Reference") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepositAccountId"); + + b.HasIndex("InvoiceId") + .HasDatabaseName("IX_Payments_InvoiceId"); + + b.HasIndex("PaymentDate"); + + b.HasIndex("RecordedById"); + + b.HasIndex("CompanyId", "PaymentDate") + .HasDatabaseName("IX_Payments_CompanyId_PaymentDate"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PendingRegistrationSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsAnnual") + .HasColumnType("bit"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Plan") + .HasColumnType("int"); + + b.Property("Token") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("PendingRegistrationSessions"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PlatformSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("GroupName") + .HasColumnType("nvarchar(max)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Label") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("PlatformSettings"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PowderUsageLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ActualLbsUsed") + .HasColumnType("decimal(18,2)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimatedLbs") + .HasColumnType("decimal(18,2)"); + + b.Property("InventoryItemId") + .HasColumnType("int"); + + b.Property("InventoryTransactionId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("JobItemCoatId") + .HasColumnType("int"); + + b.Property("JobItemId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordedAt") + .HasColumnType("datetime2"); + + b.Property("RecordedByUserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VarianceLbs") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("InventoryItemId"); + + b.HasIndex("InventoryTransactionId"); + + b.HasIndex("JobId"); + + b.HasIndex("JobItemCoatId"); + + b.HasIndex("JobItemId"); + + b.ToTable("PowderUsageLogs"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PrepService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("RequiresBlastSetup") + .HasColumnType("bit"); + + b.Property("ServiceName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("PrepServices"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PricingTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DiscountPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("TierName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("PricingTiers"); + + b.HasData( + new + { + Id = 1, + CompanyId = 0, + CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055), + Description = "Standard pricing for regular customers", + DiscountPercent = 0m, + IsActive = true, + IsDeleted = false, + TierName = "Standard" + }, + new + { + Id = 2, + CompanyId = 0, + CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063), + Description = "5% discount for preferred customers", + DiscountPercent = 5m, + IsActive = true, + IsDeleted = false, + TierName = "Preferred" + }, + new + { + Id = 3, + CompanyId = 0, + CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065), + Description = "10% discount for premium customers", + DiscountPercent = 10m, + IsActive = true, + IsDeleted = false, + TierName = "Premium" + }); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PurchaseOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillId") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("ExpectedDeliveryDate") + .HasColumnType("datetime2"); + + b.Property("InternalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OrderDate") + .HasColumnType("datetime2"); + + b.Property("PoNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceivedDate") + .HasColumnType("datetime2"); + + b.Property("ShippingCost") + .HasColumnType("decimal(18,2)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BillId"); + + b.HasIndex("VendorId"); + + b.ToTable("PurchaseOrders"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PurchaseOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("InventoryItemId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PurchaseOrderId") + .HasColumnType("int"); + + b.Property("QuantityOrdered") + .HasColumnType("decimal(18,2)"); + + b.Property("QuantityReceived") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitCost") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitOfMeasure") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("InventoryItemId"); + + b.HasIndex("PurchaseOrderId"); + + b.ToTable("PurchaseOrderItems"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Quote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovalToken") + .HasColumnType("nvarchar(450)"); + + b.Property("ApprovalTokenExpiresAt") + .HasColumnType("datetime2"); + + b.Property("ApprovalTokenUsedAt") + .HasColumnType("datetime2"); + + b.Property("ApprovedDate") + .HasColumnType("datetime2"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("ConvertedDate") + .HasColumnType("datetime2"); + + b.Property("ConvertedToJobId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("CustomerPO") + .HasColumnType("nvarchar(max)"); + + b.Property("DeclineReason") + .HasColumnType("nvarchar(max)"); + + b.Property("DeclinedByIp") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DepositAmountPaid") + .HasColumnType("decimal(18,2)"); + + b.Property("DepositPaymentIntentId") + .HasColumnType("nvarchar(max)"); + + b.Property("DepositPaymentLinkExpiresAt") + .HasColumnType("datetime2"); + + b.Property("DepositPaymentLinkToken") + .HasColumnType("nvarchar(max)"); + + b.Property("DepositPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DiscountAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("DiscountPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("DiscountReason") + .HasColumnType("nvarchar(max)"); + + b.Property("DiscountType") + .HasColumnType("int"); + + b.Property("DiscountValue") + .HasColumnType("decimal(18,2)"); + + b.Property("EquipmentCosts") + .HasColumnType("decimal(18,2)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime2"); + + b.Property("HideDiscountFromCustomer") + .HasColumnType("bit"); + + b.Property("IsCommercial") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsRushJob") + .HasColumnType("bit"); + + b.Property("ItemsSubtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("LaborCosts") + .HasColumnType("decimal(18,2)"); + + b.Property("MaterialCosts") + .HasColumnType("decimal(18,2)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OvenBatchCost") + .HasColumnType("decimal(18,2)"); + + b.Property("OvenBatches") + .HasColumnType("int"); + + b.Property("OvenCostId") + .HasColumnType("int"); + + b.Property("OvenCycleMinutes") + .HasColumnType("int"); + + b.Property("OverheadAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("OverheadPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("PreparedById") + .HasColumnType("nvarchar(450)"); + + b.Property("ProfitMargin") + .HasColumnType("decimal(18,2)"); + + b.Property("ProfitPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("ProspectAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ProspectCity") + .HasColumnType("nvarchar(max)"); + + b.Property("ProspectCompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("ProspectContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("ProspectEmail") + .HasColumnType("nvarchar(max)"); + + b.Property("ProspectPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ProspectState") + .HasColumnType("nvarchar(max)"); + + b.Property("ProspectZipCode") + .HasColumnType("nvarchar(max)"); + + b.Property("QuoteDate") + .HasColumnType("datetime2"); + + b.Property("QuoteNumber") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("QuoteStatusId") + .HasColumnType("int"); + + b.Property("RequiresDeposit") + .HasColumnType("bit"); + + b.Property("RushFee") + .HasColumnType("decimal(18,2)"); + + b.Property("SentDate") + .HasColumnType("datetime2"); + + b.Property("ShopSuppliesAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("ShopSuppliesPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("SubTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TaxAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("TaxPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("Terms") + .HasColumnType("nvarchar(max)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ApprovalToken") + .IsUnique() + .HasDatabaseName("IX_Quotes_ApprovalToken") + .HasFilter("[ApprovalToken] IS NOT NULL"); + + b.HasIndex("CompanyId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("ExpirationDate"); + + b.HasIndex("OvenCostId"); + + b.HasIndex("PreparedById"); + + b.HasIndex("QuoteStatusId"); + + b.HasIndex("CompanyId", "ExpirationDate") + .HasDatabaseName("IX_Quotes_CompanyId_ExpirationDate"); + + b.HasIndex("CompanyId", "IsDeleted"); + + b.HasIndex("CompanyId", "QuoteNumber") + .IsUnique() + .HasDatabaseName("IX_Quotes_CompanyId_QuoteNumber"); + + b.HasIndex("CompanyId", "QuoteStatusId") + .HasDatabaseName("IX_Quotes_CompanyId_QuoteStatusId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteChangeHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChangeDescription") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ChangedAt") + .HasColumnType("datetime2"); + + b.Property("ChangedByUserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ChangedByUserId"); + + b.HasIndex("CompanyId"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuoteChangeHistories"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AiPredictionId") + .HasColumnType("int"); + + b.Property("AiTags") + .HasColumnType("nvarchar(max)"); + + b.Property("CatalogItemId") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("Complexity") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("IsAiItem") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsGenericItem") + .HasColumnType("bit"); + + b.Property("IsLaborItem") + .HasColumnType("bit"); + + b.Property("IsSalesItem") + .HasColumnType("bit"); + + b.Property("ItemEquipmentCost") + .HasColumnType("decimal(18,2)"); + + b.Property("ItemLaborCost") + .HasColumnType("decimal(18,2)"); + + b.Property("ItemMaterialCost") + .HasColumnType("decimal(18,2)"); + + b.Property("ManualUnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PowderCostOverride") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,2)"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("RequiresMasking") + .HasColumnType("bit"); + + b.Property("RequiresSandblasting") + .HasColumnType("bit"); + + b.Property("Sku") + .HasColumnType("nvarchar(max)"); + + b.Property("SurfaceArea") + .HasColumnType("decimal(18,2)"); + + b.Property("SurfaceAreaSqFt") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AiPredictionId"); + + b.HasIndex("CatalogItemId"); + + b.HasIndex("QuoteId") + .HasDatabaseName("IX_QuoteItems_QuoteId"); + + b.ToTable("QuoteItems"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteItemCoat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CoatLaborCost") + .HasColumnType("decimal(18,2)"); + + b.Property("CoatMaterialCost") + .HasColumnType("decimal(18,2)"); + + b.Property("CoatName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CoatTotalCost") + .HasColumnType("decimal(18,2)"); + + b.Property("ColorCode") + .HasColumnType("nvarchar(max)"); + + b.Property("ColorName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CoverageSqFtPerLb") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Finish") + .HasColumnType("nvarchar(max)"); + + b.Property("InventoryItemId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PowderCostPerLb") + .HasColumnType("decimal(18,2)"); + + b.Property("PowderToOrder") + .HasColumnType("decimal(18,2)"); + + b.Property("QuoteItemId") + .HasColumnType("int"); + + b.Property("Sequence") + .HasColumnType("int"); + + b.Property("TransferEfficiency") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId") + .HasDatabaseName("IX_QuoteItemCoats_CompanyId"); + + b.HasIndex("InventoryItemId") + .HasDatabaseName("IX_QuoteItemCoats_InventoryItemId"); + + b.HasIndex("QuoteItemId") + .HasDatabaseName("IX_QuoteItemCoats_QuoteItemId"); + + b.HasIndex("VendorId"); + + b.ToTable("QuoteItemCoats"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteItemPrepService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BlastSetupId") + .HasColumnType("int"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PrepServiceId") + .HasColumnType("int"); + + b.Property("QuoteItemId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BlastSetupId"); + + b.HasIndex("CompanyId") + .HasDatabaseName("IX_QuoteItemPrepServices_CompanyId"); + + b.HasIndex("PrepServiceId") + .HasDatabaseName("IX_QuoteItemPrepServices_PrepServiceId"); + + b.HasIndex("QuoteItemId") + .HasDatabaseName("IX_QuoteItemPrepServices_QuoteItemId"); + + b.ToTable("QuoteItemPrepServices"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuotePhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Caption") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("IsAiAnalysisPhoto") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("TempId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("UploadedById") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.HasIndex("UploadedById"); + + b.ToTable("QuotePhotos"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuotePrepService", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PrepServiceId") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("PrepServiceId"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotePrepServices"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteStatusLookup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ColorClass") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayOrder") + .HasColumnType("int"); + + b.Property("IconClass") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsApprovedStatus") + .HasColumnType("bit"); + + b.Property("IsConvertedStatus") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsDraftStatus") + .HasColumnType("bit"); + + b.Property("IsRejectedStatus") + .HasColumnType("bit"); + + b.Property("IsSystemDefined") + .HasColumnType("bit"); + + b.Property("StatusCode") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("CompanyId", "StatusCode") + .IsUnique(); + + b.ToTable("QuoteStatusLookups"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Refund", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreditMemoId") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedById") + .HasColumnType("nvarchar(450)"); + + b.Property("IssuedDate") + .HasColumnType("datetime2"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Reference") + .HasColumnType("nvarchar(max)"); + + b.Property("RefundDate") + .HasColumnType("datetime2"); + + b.Property("RefundMethod") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CreditMemoId"); + + b.HasIndex("InvoiceId"); + + b.HasIndex("IssuedById"); + + b.HasIndex("PaymentId"); + + b.ToTable("Refunds"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ReleaseNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedByUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Version") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ReleaseNotes"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ReworkRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ActualReworkCost") + .HasColumnType("decimal(18,2)"); + + b.Property("BillingNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DefectDescription") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DiscoveredBy") + .HasColumnType("int"); + + b.Property("DiscoveredDate") + .HasColumnType("datetime2"); + + b.Property("EstimatedReworkCost") + .HasColumnType("decimal(18,2)"); + + b.Property("IsBillableToCustomer") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobId") + .HasColumnType("int"); + + b.Property("JobItemId") + .HasColumnType("int"); + + b.Property("Reason") + .HasColumnType("int"); + + b.Property("ReportedByName") + .HasColumnType("nvarchar(max)"); + + b.Property("Resolution") + .HasColumnType("int"); + + b.Property("ResolutionNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedDate") + .HasColumnType("datetime2"); + + b.Property("ReworkJobId") + .HasColumnType("int"); + + b.Property("ReworkType") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("JobId"); + + b.HasIndex("JobItemId"); + + b.HasIndex("ReworkJobId"); + + b.ToTable("ReworkRecords"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("ShopWorkers"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorkerRoleCost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("HourlyRate") + .HasColumnType("decimal(18,2)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId", "Role") + .IsUnique() + .HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role"); + + b.ToTable("ShopWorkerRoleCosts"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("EventId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceivedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("StripeWebhookEvents"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.SubscriptionPlanConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllowAccounting") + .HasColumnType("bit"); + + b.Property("AllowAiCatalogPriceCheck") + .HasColumnType("bit"); + + b.Property("AllowAiInventoryAssist") + .HasColumnType("bit"); + + b.Property("AllowAiPhotoQuotes") + .HasColumnType("bit"); + + b.Property("AllowOnlinePayments") + .HasColumnType("bit"); + + b.Property("AnnualPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaxActiveJobs") + .HasColumnType("int"); + + b.Property("MaxAiPhotoQuotesPerMonth") + .HasColumnType("int"); + + b.Property("MaxCatalogItems") + .HasColumnType("int"); + + b.Property("MaxCustomers") + .HasColumnType("int"); + + b.Property("MaxJobPhotos") + .HasColumnType("int"); + + b.Property("MaxQuotePhotos") + .HasColumnType("int"); + + b.Property("MaxQuotes") + .HasColumnType("int"); + + b.Property("MaxUsers") + .HasColumnType("int"); + + b.Property("MonthlyPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Plan") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StripePriceIdAnnual") + .HasColumnType("nvarchar(max)"); + + b.Property("StripePriceIdMonthly") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionPlanConfigs"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptedAt") + .HasColumnType("datetime2"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("IpAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("TosVersion") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserAgent") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TermsAcceptances"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CredentialId") + .IsRequired() + .HasColumnType("varbinary(900)"); + + b.Property("DeviceFriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("SignCount") + .HasColumnType("bigint"); + + b.Property("UserHandle") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId") + .IsUnique(); + + b.ToTable("UserPasskeys"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreditLimit") + .HasColumnType("decimal(18,2)"); + + b.Property("CurrentBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("DefaultExpenseAccountId") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetime2"); + + b.Property("DeletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsPreferred") + .HasColumnType("bit"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("OpeningBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("OpeningBalanceDate") + .HasColumnType("datetime2"); + + b.Property("PaymentTerms") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TaxId") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("ZipCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("DefaultExpenseAccountId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Account", b => + { + b.HasOne("PowderCoating.Core.Entities.Account", "ParentAccount") + .WithMany("SubAccounts") + .HasForeignKey("ParentAccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ParentAccount"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.AiUsageLog", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", "Company") + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.AnnouncementDismissal", b => + { + b.HasOne("PowderCoating.Core.Entities.Announcement", "Announcement") + .WithMany("Dismissals") + .HasForeignKey("AnnouncementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Announcement"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ApplicationUser", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", "Company") + .WithMany("Users") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Appointment", b => + { + b.HasOne("PowderCoating.Core.Entities.AppointmentStatusLookup", "AppointmentStatus") + .WithMany("Appointments") + .HasForeignKey("AppointmentStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.AppointmentTypeLookup", "AppointmentType") + .WithMany("Appointments") + .HasForeignKey("AppointmentTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser") + .WithMany() + .HasForeignKey("AssignedUserId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId"); + + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId"); + + b.Navigation("AppointmentStatus"); + + b.Navigation("AppointmentType"); + + b.Navigation("AssignedUser"); + + b.Navigation("Customer"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Bill", b => + { + b.HasOne("PowderCoating.Core.Entities.Account", "APAccount") + .WithMany("Bills") + .HasForeignKey("APAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Vendor", "Vendor") + .WithMany("Bills") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("APAccount"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.BillLineItem", b => + { + b.HasOne("PowderCoating.Core.Entities.Account", "Account") + .WithMany("BillLineItems") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PowderCoating.Core.Entities.Bill", "Bill") + .WithMany("LineItems") + .HasForeignKey("BillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId"); + + b.Navigation("Account"); + + b.Navigation("Bill"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.BillPayment", b => + { + b.HasOne("PowderCoating.Core.Entities.Account", "BankAccount") + .WithMany("BillPayments") + .HasForeignKey("BankAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Bill", "Bill") + .WithMany("Payments") + .HasForeignKey("BillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Vendor", "Vendor") + .WithMany("BillPayments") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BankAccount"); + + b.Navigation("Bill"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.BugReportAttachment", b => + { + b.HasOne("PowderCoating.Core.Entities.BugReport", "BugReport") + .WithMany("Attachments") + .HasForeignKey("BugReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BugReport"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CatalogCategory", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.CatalogCategory", "ParentCategory") + .WithMany("SubCategories") + .HasForeignKey("ParentCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ParentCategory"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CatalogItem", b => + { + b.HasOne("PowderCoating.Core.Entities.CatalogCategory", "Category") + .WithMany("Items") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Account", "CogsAccount") + .WithMany() + .HasForeignKey("CogsAccountId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId"); + + b.HasOne("PowderCoating.Core.Entities.Account", "RevenueAccount") + .WithMany() + .HasForeignKey("RevenueAccountId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Category"); + + b.Navigation("CogsAccount"); + + b.Navigation("InventoryItem"); + + b.Navigation("RevenueAccount"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CompanyBlastSetup", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", "Company") + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CompanyOperatingCosts", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", "Company") + .WithOne("OperatingCosts") + .HasForeignKey("PowderCoating.Core.Entities.CompanyOperatingCosts", "CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CompanyPreferences", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", "Company") + .WithOne("Preferences") + .HasForeignKey("PowderCoating.Core.Entities.CompanyPreferences", "CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CreditMemo", b => + { + b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy") + .WithMany() + .HasForeignKey("IssuedById"); + + b.HasOne("PowderCoating.Core.Entities.Invoice", "OriginalInvoice") + .WithMany() + .HasForeignKey("OriginalInvoiceId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.ReworkRecord", "ReworkRecord") + .WithMany() + .HasForeignKey("ReworkRecordId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Customer"); + + b.Navigation("IssuedBy"); + + b.Navigation("OriginalInvoice"); + + b.Navigation("ReworkRecord"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CreditMemoApplication", b => + { + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AppliedBy") + .WithMany() + .HasForeignKey("AppliedById"); + + b.HasOne("PowderCoating.Core.Entities.CreditMemo", "CreditMemo") + .WithMany("Applications") + .HasForeignKey("CreditMemoId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Invoice", "Invoice") + .WithMany("CreditApplications") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AppliedBy"); + + b.Navigation("CreditMemo"); + + b.Navigation("Invoice"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany("Customers") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.PricingTier", "PricingTier") + .WithMany("Customers") + .HasForeignKey("PricingTierId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PricingTier"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b => + { + b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") + .WithMany("CustomerNotes") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b => + { + b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice") + .WithMany() + .HasForeignKey("AppliedToInvoiceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId"); + + b.HasOne("PowderCoating.Core.Entities.Quote", "Quote") + .WithMany() + .HasForeignKey("QuoteId"); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "RecordedBy") + .WithMany() + .HasForeignKey("RecordedById"); + + b.Navigation("AppliedToInvoice"); + + b.Navigation("Customer"); + + b.Navigation("Job"); + + b.Navigation("Quote"); + + b.Navigation("RecordedBy"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany("Equipment") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Expense", b => + { + b.HasOne("PowderCoating.Core.Entities.Account", "ExpenseAccount") + .WithMany("Expenses") + .HasForeignKey("ExpenseAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId"); + + b.HasOne("PowderCoating.Core.Entities.Account", "PaymentAccount") + .WithMany("ExpensePaymentAccounts") + .HasForeignKey("PaymentAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Vendor", "Vendor") + .WithMany("Expenses") + .HasForeignKey("VendorId"); + + b.Navigation("ExpenseAccount"); + + b.Navigation("Job"); + + b.Navigation("PaymentAccount"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b => + { + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy") + .WithMany() + .HasForeignKey("IssuedById"); + + b.HasOne("PowderCoating.Core.Entities.Customer", "PurchasingCustomer") + .WithMany() + .HasForeignKey("PurchasingCustomerId"); + + b.HasOne("PowderCoating.Core.Entities.Customer", "RecipientCustomer") + .WithMany() + .HasForeignKey("RecipientCustomerId"); + + b.Navigation("IssuedBy"); + + b.Navigation("PurchasingCustomer"); + + b.Navigation("RecipientCustomer"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificateRedemption", b => + { + b.HasOne("PowderCoating.Core.Entities.GiftCertificate", "GiftCertificate") + .WithMany("Redemptions") + .HasForeignKey("GiftCertificateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Invoice", "Invoice") + .WithMany("GiftCertificateRedemptions") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "RedeemedBy") + .WithMany() + .HasForeignKey("RedeemedById"); + + b.Navigation("GiftCertificate"); + + b.Navigation("Invoice"); + + b.Navigation("RedeemedBy"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InAppNotification", b => + { + b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId"); + + b.HasOne("PowderCoating.Core.Entities.Invoice", "Invoice") + .WithMany() + .HasForeignKey("InvoiceId"); + + b.HasOne("PowderCoating.Core.Entities.Quote", "Quote") + .WithMany() + .HasForeignKey("QuoteId"); + + b.Navigation("Customer"); + + b.Navigation("Invoice"); + + b.Navigation("Quote"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InventoryCategoryLookup", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InventoryItem", b => + { + b.HasOne("PowderCoating.Core.Entities.Account", "CogsAccount") + .WithMany() + .HasForeignKey("CogsAccountId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany("InventoryItems") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Account", "InventoryAccount") + .WithMany() + .HasForeignKey("InventoryAccountId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.InventoryCategoryLookup", "InventoryCategory") + .WithMany("InventoryItems") + .HasForeignKey("InventoryCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PowderCoating.Core.Entities.Vendor", "PrimaryVendor") + .WithMany("InventoryItems") + .HasForeignKey("PrimaryVendorId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CogsAccount"); + + b.Navigation("InventoryAccount"); + + b.Navigation("InventoryCategory"); + + b.Navigation("PrimaryVendor"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InventoryTransaction", b => + { + b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem") + .WithMany("Transactions") + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("InventoryItem"); + + b.Navigation("Job"); + + b.Navigation("PurchaseOrder"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Invoice", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") + .WithMany("Invoices") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithOne("Invoice") + .HasForeignKey("PowderCoating.Core.Entities.Invoice", "JobId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "PreparedBy") + .WithMany() + .HasForeignKey("PreparedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.Account", "SalesTaxAccount") + .WithMany() + .HasForeignKey("SalesTaxAccountId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Customer"); + + b.Navigation("Job"); + + b.Navigation("PreparedBy"); + + b.Navigation("SalesTaxAccount"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InvoiceItem", b => + { + b.HasOne("PowderCoating.Core.Entities.CatalogItem", "CatalogItem") + .WithMany() + .HasForeignKey("CatalogItemId"); + + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.GiftCertificate", "GeneratedGiftCertificate") + .WithMany() + .HasForeignKey("GeneratedGiftCertificateId"); + + b.HasOne("PowderCoating.Core.Entities.Invoice", "Invoice") + .WithMany("InvoiceItems") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Account", "RevenueAccount") + .WithMany() + .HasForeignKey("RevenueAccountId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.JobItem", "SourceJobItem") + .WithMany() + .HasForeignKey("SourceJobItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CatalogItem"); + + b.Navigation("GeneratedGiftCertificate"); + + b.Navigation("Invoice"); + + b.Navigation("RevenueAccount"); + + b.Navigation("SourceJobItem"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Job", b => + { + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser") + .WithMany() + .HasForeignKey("AssignedUserId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany("Jobs") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") + .WithMany("Jobs") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IntakeCheckedBy") + .WithMany() + .HasForeignKey("IntakeCheckedByUserId"); + + b.HasOne("PowderCoating.Core.Entities.JobPriorityLookup", "JobPriority") + .WithMany("Jobs") + .HasForeignKey("JobPriorityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.JobStatusLookup", "JobStatus") + .WithMany("Jobs") + .HasForeignKey("JobStatusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Job", "OriginalJob") + .WithMany() + .HasForeignKey("OriginalJobId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.OvenCost", "OvenCost") + .WithMany("Jobs") + .HasForeignKey("OvenCostId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PowderCoating.Core.Entities.Quote", "Quote") + .WithOne("ConvertedToJob") + .HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.ShopWorker", null) + .WithMany("AssignedJobs") + .HasForeignKey("ShopWorkerId"); + + b.Navigation("AssignedUser"); + + b.Navigation("Customer"); + + b.Navigation("IntakeCheckedBy"); + + b.Navigation("JobPriority"); + + b.Navigation("JobStatus"); + + b.Navigation("OriginalJob"); + + b.Navigation("OvenCost"); + + b.Navigation("Quote"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobChangeHistory", b => + { + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "ChangedBy") + .WithMany() + .HasForeignKey("ChangedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChangedBy"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobDailyPriority", b => + { + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobItem", b => + { + b.HasOne("PowderCoating.Core.Entities.AiItemPrediction", "AiPrediction") + .WithMany() + .HasForeignKey("AiPredictionId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.CatalogItem", "CatalogItem") + .WithMany() + .HasForeignKey("CatalogItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany("JobItems") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AiPrediction"); + + b.Navigation("CatalogItem"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobItemCoat", b => + { + b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId"); + + b.HasOne("PowderCoating.Core.Entities.JobItem", "JobItem") + .WithMany("Coats") + .HasForeignKey("JobItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId"); + + b.Navigation("InventoryItem"); + + b.Navigation("JobItem"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobItemPrepService", b => + { + b.HasOne("PowderCoating.Core.Entities.CompanyBlastSetup", "BlastSetup") + .WithMany() + .HasForeignKey("BlastSetupId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.JobItem", "JobItem") + .WithMany("PrepServices") + .HasForeignKey("JobItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.PrepService", "PrepService") + .WithMany() + .HasForeignKey("PrepServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BlastSetup"); + + b.Navigation("JobItem"); + + b.Navigation("PrepService"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobNote", b => + { + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany("Notes") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobPhoto", b => + { + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany("Photos") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "UploadedBy") + .WithMany() + .HasForeignKey("UploadedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + + b.Navigation("UploadedBy"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobPrepService", b => + { + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany("JobPrepServices") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.PrepService", "PrepService") + .WithMany() + .HasForeignKey("PrepServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + + b.Navigation("PrepService"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobPriorityLookup", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobStatusHistory", b => + { + b.HasOne("PowderCoating.Core.Entities.JobStatusLookup", "FromStatus") + .WithMany("FromStatusHistory") + .HasForeignKey("FromStatusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany("StatusHistory") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.JobStatusLookup", "ToStatus") + .WithMany("ToStatusHistory") + .HasForeignKey("ToStatusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromStatus"); + + b.Navigation("Job"); + + b.Navigation("ToStatus"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobStatusLookup", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTemplate", b => + { + b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId"); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTemplateItem", b => + { + b.HasOne("PowderCoating.Core.Entities.CatalogItem", "CatalogItem") + .WithMany() + .HasForeignKey("CatalogItemId"); + + b.HasOne("PowderCoating.Core.Entities.JobTemplate", "JobTemplate") + .WithMany("Items") + .HasForeignKey("JobTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CatalogItem"); + + b.Navigation("JobTemplate"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTemplateItemCoat", b => + { + b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId"); + + b.HasOne("PowderCoating.Core.Entities.JobTemplateItem", "JobTemplateItem") + .WithMany("Coats") + .HasForeignKey("JobTemplateItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId"); + + b.Navigation("InventoryItem"); + + b.Navigation("JobTemplateItem"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTemplateItemPrepService", b => + { + b.HasOne("PowderCoating.Core.Entities.JobTemplateItem", "JobTemplateItem") + .WithMany("PrepServices") + .HasForeignKey("JobTemplateItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.PrepService", "PrepService") + .WithMany() + .HasForeignKey("PrepServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobTemplateItem"); + + b.Navigation("PrepService"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTimeEntry", b => + { + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany("TimeEntries") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker") + .WithMany("TimeEntries") + .HasForeignKey("ShopWorkerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + + b.Navigation("Worker"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b => + { + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser") + .WithMany() + .HasForeignKey("AssignedUserId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.Equipment", "Equipment") + .WithMany("MaintenanceRecords") + .HasForeignKey("EquipmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "PerformedBy") + .WithMany("PerformedMaintenances") + .HasForeignKey("PerformedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.MaintenanceRecord", "RecurrenceParent") + .WithMany() + .HasForeignKey("RecurrenceParentId"); + + b.HasOne("PowderCoating.Core.Entities.ShopWorker", null) + .WithMany("AssignedMaintenanceTasks") + .HasForeignKey("ShopWorkerId"); + + b.Navigation("AssignedUser"); + + b.Navigation("Equipment"); + + b.Navigation("PerformedBy"); + + b.Navigation("RecurrenceParent"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.NotificationLog", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") + .WithMany("NotificationLogs") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.Invoice", "Invoice") + .WithMany() + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.Quote", "Quote") + .WithMany() + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Customer"); + + b.Navigation("Invoice"); + + b.Navigation("Job"); + + b.Navigation("Quote"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.NotificationTemplate", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", "Company") + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.OvenBatch", b => + { + b.HasOne("PowderCoating.Core.Entities.Equipment", "Equipment") + .WithMany("OvenBatches") + .HasForeignKey("EquipmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PowderCoating.Core.Entities.OvenCost", "OvenCost") + .WithMany() + .HasForeignKey("OvenCostId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Equipment"); + + b.Navigation("OvenCost"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.OvenBatchItem", b => + { + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.JobItemCoat", "JobItemCoat") + .WithMany() + .HasForeignKey("JobItemCoatId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.JobItem", "JobItem") + .WithMany() + .HasForeignKey("JobItemId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.OvenBatch", "Batch") + .WithMany("Items") + .HasForeignKey("OvenBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Job"); + + b.Navigation("JobItem"); + + b.Navigation("JobItemCoat"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.OvenCost", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", "Company") + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Payment", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Account", "DepositAccount") + .WithMany() + .HasForeignKey("DepositAccountId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.Invoice", "Invoice") + .WithMany("Payments") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "RecordedBy") + .WithMany() + .HasForeignKey("RecordedById") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DepositAccount"); + + b.Navigation("Invoice"); + + b.Navigation("RecordedBy"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PowderUsageLog", b => + { + b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.InventoryTransaction", "InventoryTransaction") + .WithMany() + .HasForeignKey("InventoryTransactionId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany() + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.JobItemCoat", "JobItemCoat") + .WithMany() + .HasForeignKey("JobItemCoatId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.JobItem", "JobItem") + .WithMany() + .HasForeignKey("JobItemId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("InventoryItem"); + + b.Navigation("InventoryTransaction"); + + b.Navigation("Job"); + + b.Navigation("JobItem"); + + b.Navigation("JobItemCoat"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PricingTier", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany("PricingTiers") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PurchaseOrder", b => + { + b.HasOne("PowderCoating.Core.Entities.Bill", "Bill") + .WithMany() + .HasForeignKey("BillId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Bill"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PurchaseOrderItem", b => + { + b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Items") + .HasForeignKey("PurchaseOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("InventoryItem"); + + b.Navigation("PurchaseOrder"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Quote", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany("Quotes") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") + .WithMany("Quotes") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PowderCoating.Core.Entities.OvenCost", "OvenCost") + .WithMany("Quotes") + .HasForeignKey("OvenCostId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "PreparedBy") + .WithMany("PreparedQuotes") + .HasForeignKey("PreparedById") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.QuoteStatusLookup", "QuoteStatus") + .WithMany("Quotes") + .HasForeignKey("QuoteStatusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("OvenCost"); + + b.Navigation("PreparedBy"); + + b.Navigation("QuoteStatus"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteChangeHistory", b => + { + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "ChangedBy") + .WithMany() + .HasForeignKey("ChangedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Quote", "Quote") + .WithMany() + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChangedBy"); + + b.Navigation("Quote"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteItem", b => + { + b.HasOne("PowderCoating.Core.Entities.AiItemPrediction", "AiPrediction") + .WithMany() + .HasForeignKey("AiPredictionId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.CatalogItem", "CatalogItem") + .WithMany() + .HasForeignKey("CatalogItemId"); + + b.HasOne("PowderCoating.Core.Entities.Quote", "Quote") + .WithMany("QuoteItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AiPrediction"); + + b.Navigation("CatalogItem"); + + b.Navigation("Quote"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteItemCoat", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem") + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PowderCoating.Core.Entities.QuoteItem", "QuoteItem") + .WithMany("Coats") + .HasForeignKey("QuoteItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId"); + + b.Navigation("InventoryItem"); + + b.Navigation("QuoteItem"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteItemPrepService", b => + { + b.HasOne("PowderCoating.Core.Entities.CompanyBlastSetup", "BlastSetup") + .WithMany() + .HasForeignKey("BlastSetupId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.PrepService", "PrepService") + .WithMany() + .HasForeignKey("PrepServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.QuoteItem", "QuoteItem") + .WithMany("PrepServices") + .HasForeignKey("QuoteItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BlastSetup"); + + b.Navigation("PrepService"); + + b.Navigation("QuoteItem"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuotePhoto", b => + { + b.HasOne("PowderCoating.Core.Entities.Quote", "Quote") + .WithMany("QuotePhotos") + .HasForeignKey("QuoteId"); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "UploadedBy") + .WithMany() + .HasForeignKey("UploadedById"); + + b.Navigation("Quote"); + + b.Navigation("UploadedBy"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuotePrepService", b => + { + b.HasOne("PowderCoating.Core.Entities.PrepService", "PrepService") + .WithMany() + .HasForeignKey("PrepServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Quote", "Quote") + .WithMany("QuotePrepServices") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PrepService"); + + b.Navigation("Quote"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteStatusLookup", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Refund", b => + { + b.HasOne("PowderCoating.Core.Entities.CreditMemo", "CreditMemo") + .WithMany() + .HasForeignKey("CreditMemoId"); + + b.HasOne("PowderCoating.Core.Entities.Invoice", "Invoice") + .WithMany("Refunds") + .HasForeignKey("InvoiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy") + .WithMany() + .HasForeignKey("IssuedById"); + + b.HasOne("PowderCoating.Core.Entities.Payment", "Payment") + .WithMany() + .HasForeignKey("PaymentId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("CreditMemo"); + + b.Navigation("Invoice"); + + b.Navigation("IssuedBy"); + + b.Navigation("Payment"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ReworkRecord", b => + { + b.HasOne("PowderCoating.Core.Entities.Job", "Job") + .WithMany("ReworkRecords") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.JobItem", "JobItem") + .WithMany() + .HasForeignKey("JobItemId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("PowderCoating.Core.Entities.Job", "ReworkJob") + .WithMany() + .HasForeignKey("ReworkJobId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Job"); + + b.Navigation("JobItem"); + + b.Navigation("ReworkJob"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany("ShopWorkers") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b => + { + b.HasOne("PowderCoating.Core.Entities.Company", null) + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PowderCoating.Core.Entities.Account", "DefaultExpenseAccount") + .WithMany() + .HasForeignKey("DefaultExpenseAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DefaultExpenseAccount"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Account", b => + { + b.Navigation("BillLineItems"); + + b.Navigation("BillPayments"); + + b.Navigation("Bills"); + + b.Navigation("ExpensePaymentAccounts"); + + b.Navigation("Expenses"); + + b.Navigation("SubAccounts"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Announcement", b => + { + b.Navigation("Dismissals"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ApplicationUser", b => + { + b.Navigation("PerformedMaintenances"); + + b.Navigation("PreparedQuotes"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.AppointmentStatusLookup", b => + { + b.Navigation("Appointments"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.AppointmentTypeLookup", b => + { + b.Navigation("Appointments"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Bill", b => + { + b.Navigation("LineItems"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.BugReport", b => + { + b.Navigation("Attachments"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CatalogCategory", b => + { + b.Navigation("Items"); + + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Company", b => + { + b.Navigation("Customers"); + + b.Navigation("Equipment"); + + b.Navigation("InventoryItems"); + + b.Navigation("Jobs"); + + b.Navigation("OperatingCosts"); + + b.Navigation("Preferences"); + + b.Navigation("PricingTiers"); + + b.Navigation("Quotes"); + + b.Navigation("ShopWorkers"); + + b.Navigation("Users"); + + b.Navigation("Vendors"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.CreditMemo", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b => + { + b.Navigation("CustomerNotes"); + + b.Navigation("Invoices"); + + b.Navigation("Jobs"); + + b.Navigation("NotificationLogs"); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b => + { + b.Navigation("MaintenanceRecords"); + + b.Navigation("OvenBatches"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b => + { + b.Navigation("Redemptions"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InventoryCategoryLookup", b => + { + b.Navigation("InventoryItems"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.InventoryItem", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Invoice", b => + { + b.Navigation("CreditApplications"); + + b.Navigation("GiftCertificateRedemptions"); + + b.Navigation("InvoiceItems"); + + b.Navigation("Payments"); + + b.Navigation("Refunds"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Job", b => + { + b.Navigation("Invoice"); + + b.Navigation("JobItems"); + + b.Navigation("JobPrepServices"); + + b.Navigation("Notes"); + + b.Navigation("Photos"); + + b.Navigation("ReworkRecords"); + + b.Navigation("StatusHistory"); + + b.Navigation("TimeEntries"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobItem", b => + { + b.Navigation("Coats"); + + b.Navigation("PrepServices"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobPriorityLookup", b => + { + b.Navigation("Jobs"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobStatusLookup", b => + { + b.Navigation("FromStatusHistory"); + + b.Navigation("Jobs"); + + b.Navigation("ToStatusHistory"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.JobTemplateItem", b => + { + b.Navigation("Coats"); + + b.Navigation("PrepServices"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.OvenBatch", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.OvenCost", b => + { + b.Navigation("Jobs"); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PricingTier", b => + { + b.Navigation("Customers"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.PurchaseOrder", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Quote", b => + { + b.Navigation("ConvertedToJob"); + + b.Navigation("QuoteItems"); + + b.Navigation("QuotePhotos"); + + b.Navigation("QuotePrepServices"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteItem", b => + { + b.Navigation("Coats"); + + b.Navigation("PrepServices"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.QuoteStatusLookup", b => + { + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b => + { + b.Navigation("AssignedJobs"); + + b.Navigation("AssignedMaintenanceTasks"); + + b.Navigation("TimeEntries"); + }); + + modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b => + { + b.Navigation("BillPayments"); + + b.Navigation("Bills"); + + b.Navigation("Expenses"); + + b.Navigation("InventoryItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/PowderCoating.Infrastructure/Migrations/20260428164026_AddGuidedActivationFields.cs b/src/PowderCoating.Infrastructure/Migrations/20260428164026_AddGuidedActivationFields.cs new file mode 100644 index 0000000..1ea643d --- /dev/null +++ b/src/PowderCoating.Infrastructure/Migrations/20260428164026_AddGuidedActivationFields.cs @@ -0,0 +1,132 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PowderCoating.Infrastructure.Migrations +{ + /// + public partial class AddGuidedActivationFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FirstInvoiceCreatedAt", + table: "CompanyPreferences", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "FirstJobCreatedAt", + table: "CompanyPreferences", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "FirstQuoteCreatedAt", + table: "CompanyPreferences", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "FirstWorkflowCompleted", + table: "CompanyPreferences", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "FirstWorkflowCompletedAt", + table: "CompanyPreferences", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "GuidedActivationDismissedAt", + table: "CompanyPreferences", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "OnboardingPath", + table: "CompanyPreferences", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.UpdateData( + table: "PricingTiers", + keyColumn: "Id", + keyValue: 1, + column: "CreatedAt", + value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055)); + + migrationBuilder.UpdateData( + table: "PricingTiers", + keyColumn: "Id", + keyValue: 2, + column: "CreatedAt", + value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063)); + + migrationBuilder.UpdateData( + table: "PricingTiers", + keyColumn: "Id", + keyValue: 3, + column: "CreatedAt", + value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FirstInvoiceCreatedAt", + table: "CompanyPreferences"); + + migrationBuilder.DropColumn( + name: "FirstJobCreatedAt", + table: "CompanyPreferences"); + + migrationBuilder.DropColumn( + name: "FirstQuoteCreatedAt", + table: "CompanyPreferences"); + + migrationBuilder.DropColumn( + name: "FirstWorkflowCompleted", + table: "CompanyPreferences"); + + migrationBuilder.DropColumn( + name: "FirstWorkflowCompletedAt", + table: "CompanyPreferences"); + + migrationBuilder.DropColumn( + name: "GuidedActivationDismissedAt", + table: "CompanyPreferences"); + + migrationBuilder.DropColumn( + name: "OnboardingPath", + table: "CompanyPreferences"); + + migrationBuilder.UpdateData( + table: "PricingTiers", + keyColumn: "Id", + keyValue: 1, + column: "CreatedAt", + value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921)); + + migrationBuilder.UpdateData( + table: "PricingTiers", + keyColumn: "Id", + keyValue: 2, + column: "CreatedAt", + value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931)); + + migrationBuilder.UpdateData( + table: "PricingTiers", + keyColumn: "Id", + keyValue: 3, + column: "CreatedAt", + value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932)); + } + } +} diff --git a/src/PowderCoating.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/PowderCoating.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index bc7224d..c37c60d 100644 --- a/src/PowderCoating.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/PowderCoating.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1969,6 +1969,24 @@ namespace PowderCoating.Infrastructure.Migrations b.Property("EmailNotificationsEnabled") .HasColumnType("bit"); + b.Property("FirstInvoiceCreatedAt") + .HasColumnType("datetime2"); + + b.Property("FirstJobCreatedAt") + .HasColumnType("datetime2"); + + b.Property("FirstQuoteCreatedAt") + .HasColumnType("datetime2"); + + b.Property("FirstWorkflowCompleted") + .HasColumnType("bit"); + + b.Property("FirstWorkflowCompletedAt") + .HasColumnType("datetime2"); + + b.Property("GuidedActivationDismissedAt") + .HasColumnType("datetime2"); + b.Property("InAccentColor") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -2017,6 +2035,9 @@ namespace PowderCoating.Infrastructure.Migrations b.Property("NotifyOnQuoteApproval") .HasColumnType("bit"); + b.Property("OnboardingPath") + .HasColumnType("nvarchar(max)"); + b.Property("PaymentReminderDays") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -5839,7 +5860,7 @@ namespace PowderCoating.Infrastructure.Migrations { Id = 1, CompanyId = 0, - CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921), + CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055), Description = "Standard pricing for regular customers", DiscountPercent = 0m, IsActive = true, @@ -5850,7 +5871,7 @@ namespace PowderCoating.Infrastructure.Migrations { Id = 2, CompanyId = 0, - CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931), + CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063), Description = "5% discount for preferred customers", DiscountPercent = 5m, IsActive = true, @@ -5861,7 +5882,7 @@ namespace PowderCoating.Infrastructure.Migrations { Id = 3, CompanyId = 0, - CreatedAt = new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932), + CreatedAt = new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065), Description = "10% discount for premium customers", DiscountPercent = 10m, IsActive = true, diff --git a/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs b/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs new file mode 100644 index 0000000..ef7eed9 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs @@ -0,0 +1,105 @@ +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 provides domain-specific multi-level +/// include queries previously expressed inline in BillsController. +/// +public class BillRepository : Repository, IBillRepository +{ + public BillRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task LoadForViewAsync(int id) + { + return await _context.Bills + .Where(b => b.Id == id && !b.IsDeleted) + .Include(b => b.Vendor) + .Include(b => b.APAccount) + .Include(b => b.LineItems.Where(li => !li.IsDeleted)) + .ThenInclude(li => li.Account) + .Include(b => b.LineItems.Where(li => !li.IsDeleted)) + .ThenInclude(li => li.Job) + .Include(b => b.Payments.Where(p => !p.IsDeleted)) + .ThenInclude(p => p.BankAccount) + .FirstOrDefaultAsync(); + } + + /// + public async Task LoadForEditAsync(int id) + { + return await _context.Bills + .Where(b => b.Id == id && !b.IsDeleted) + .Include(b => b.LineItems.Where(li => !li.IsDeleted)) + .FirstOrDefaultAsync(); + } + + /// + public async Task> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount) + { + var query = _context.Bills + .Include(b => b.Vendor) + .Include(b => b.LineItems.Where(li => !li.IsDeleted)) + .Where(b => !b.IsDeleted); + + if (statusFilter == "Unpaid") + query = query.Where(b => b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid); + else if (statusFilter == "Overdue") + query = query.Where(b => b.Status != BillStatus.Paid && b.Status != BillStatus.Voided && + b.DueDate.HasValue && b.DueDate.Value.Date < DateTime.Today); + + if (!string.IsNullOrEmpty(searchTerm)) + { + var term = searchTerm; + query = query.Where(b => + b.BillNumber.Contains(term) || + b.Vendor.CompanyName.Contains(term) || + (b.VendorInvoiceNumber != null && b.VendorInvoiceNumber.Contains(term)) || + (b.Memo != null && b.Memo.Contains(term)) || + b.LineItems.Any(li => li.Description.Contains(term)) || + (searchAmount.HasValue && (b.Total == searchAmount.Value || b.AmountPaid == searchAmount.Value))); + } + + return await query.OrderByDescending(b => b.BillDate).ToListAsync(); + } + + /// + public async Task GetLastBillNumberAsync(string prefix) + { + return await _context.Bills + .IgnoreQueryFilters() + .Where(b => b.BillNumber.StartsWith(prefix)) + .OrderByDescending(b => b.BillNumber) + .Select(b => b.BillNumber) + .FirstOrDefaultAsync(); + } + + /// + public async Task GetLastPaymentNumberAsync(string prefix) + { + return await _context.BillPayments + .IgnoreQueryFilters() + .Where(p => p.PaymentNumber.StartsWith(prefix)) + .OrderByDescending(p => p.PaymentNumber) + .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/CustomerRepository.cs b/src/PowderCoating.Infrastructure/Repositories/CustomerRepository.cs new file mode 100644 index 0000000..175be52 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/CustomerRepository.cs @@ -0,0 +1,32 @@ +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 domain-specific queries on top of +/// the generic CRUD interface. +/// +public class CustomerRepository : Repository, ICustomerRepository +{ + public CustomerRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task LoadForDetailsAsync(int id) + { + return await _context.Customers + .Where(c => c.Id == id && !c.IsDeleted) + .Include(c => c.PricingTier) + .Include(c => c.CustomerNotes.Where(n => !n.IsDeleted)) + .FirstOrDefaultAsync(); + } + + /// + public async Task FindByEmailAsync(string email) + { + return await _context.Customers + .FirstOrDefaultAsync(c => c.Email == email && !c.IsDeleted); + } +} 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/InvoiceRepository.cs b/src/PowderCoating.Infrastructure/Repositories/InvoiceRepository.cs new file mode 100644 index 0000000..a98f81f --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/InvoiceRepository.cs @@ -0,0 +1,104 @@ +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 provides domain-specific multi-level +/// include queries previously expressed inline in InvoicesController.LoadInvoiceForViewAsync. +/// +public class InvoiceRepository : Repository, IInvoiceRepository +{ + public InvoiceRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task LoadForViewAsync(int id) + { + return await _context.Set() + .Where(i => i.Id == id && !i.IsDeleted) + .Include(i => i.Customer) + .Include(i => i.Job) + .Include(i => i.PreparedBy) + .Include(i => i.SalesTaxAccount) + .Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted)) + .ThenInclude(ii => ii.RevenueAccount) + .Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted)) + .ThenInclude(ii => ii.GeneratedGiftCertificate) + .Include(i => i.Payments.Where(p => !p.IsDeleted)) + .ThenInclude(p => p.RecordedBy) + .Include(i => i.Payments.Where(p => !p.IsDeleted)) + .ThenInclude(p => p.DepositAccount) + .Include(i => i.Refunds.Where(r => !r.IsDeleted)) + .ThenInclude(r => r.IssuedBy) + .Include(i => i.CreditApplications.Where(ca => !ca.IsDeleted)) + .ThenInclude(ca => ca.CreditMemo) + .Include(i => i.GiftCertificateRedemptions) + .FirstOrDefaultAsync(); + } + + /// + public async Task GetForJobAsync(int jobId, bool includeDeleted = false) + { + var query = _context.Set().Where(i => i.JobId == jobId); + if (!includeDeleted) + query = query.Where(i => !i.IsDeleted); + else + query = query.IgnoreQueryFilters().Where(i => i.JobId == jobId); + return await query.FirstOrDefaultAsync(); + } + + /// + public async Task GetByPaymentTokenAsync(string token) + { + return await _context.Set() + .IgnoreQueryFilters() + .Include(i => i.Customer) + .Include(i => i.Job) + .Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted)) + .FirstOrDefaultAsync(i => i.PaymentLinkToken == token && !i.IsDeleted); + } + + /// + public async Task GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix) + { + return await _context.Set() + .IgnoreQueryFilters() + .Where(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix)) + .OrderByDescending(i => i.InvoiceNumber) + .Select(i => i.InvoiceNumber) + .FirstOrDefaultAsync(); + } + + /// + public async Task> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to) + { + return await _context.Set() + .AsNoTracking() + .Include(i => i.Customer) + .Where(i => !i.IsDeleted + && i.CompanyId == companyId + && i.OnlineAmountPaid > 0 + && i.UpdatedAt >= from + && i.UpdatedAt < to) + .OrderByDescending(i => i.UpdatedAt) + .ToListAsync(); + } + + /// + public async Task> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to) + { + return await _context.Set() + .AsNoTracking() + .Include(r => r.Invoice).ThenInclude(inv => inv!.Customer) + .Where(r => !r.IsDeleted + && r.CompanyId == companyId + && r.RefundMethod == PaymentMethod.CreditDebitCard + && r.RefundDate >= from + && r.RefundDate < to) + .OrderByDescending(r => r.RefundDate) + .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 new file mode 100644 index 0000000..08ffe70 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs @@ -0,0 +1,190 @@ +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 read queries +/// that were previously scattered as inline EF expressions inside controllers. +/// +public class JobRepository : Repository, IJobRepository +{ + public JobRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task> GetBoardJobsAsync() + { + return await _context.Jobs + .AsNoTracking() + .Where(j => !j.IsDeleted) + .Include(j => j.Customer) + .Include(j => j.JobStatus) + .Include(j => j.JobPriority) + .Include(j => j.AssignedUser) + .OrderBy(j => j.DueDate.HasValue ? 0 : 1) + .ThenBy(j => j.DueDate) + .ThenBy(j => j.JobPriority!.DisplayOrder) + .ToListAsync(); + } + + /// + public async Task LoadForDetailsAsync(int id) + { + // Single query replaces the per-item N+1 loop that was in JobsController.Details. + // EF Core splits the multi-level ThenIncludes across two SQL queries automatically + // (split query behavior), keeping result set size manageable. + return await _context.Jobs + .Where(j => j.Id == id && !j.IsDeleted) + .Include(j => j.Customer) + .Include(j => j.JobStatus) + .Include(j => j.JobPriority) + .Include(j => j.AssignedUser) + .Include(j => j.Quote) + .Include(j => j.OvenCost) + .Include(j => j.OriginalJob) + .Include(j => j.IntakeCheckedBy) + .Include(j => j.JobItems.Where(ji => !ji.IsDeleted)) + .ThenInclude(ji => ji.Coats) + .ThenInclude(c => c.InventoryItem) + .Include(j => j.JobItems.Where(ji => !ji.IsDeleted)) + .ThenInclude(ji => ji.Coats) + .ThenInclude(c => c.Vendor) + .Include(j => j.JobItems.Where(ji => !ji.IsDeleted)) + .ThenInclude(ji => ji.PrepServices) + .ThenInclude(ps => ps.PrepService) + .Include(j => j.JobPrepServices.Where(jps => !jps.IsDeleted)) + .ThenInclude(jps => jps.PrepService) + .FirstOrDefaultAsync(); + } + + /// + public async Task LoadForEditAsync(int id) + { + return await _context.Jobs + .Where(j => j.Id == id && !j.IsDeleted) + .Include(j => j.Customer) + .Include(j => j.JobStatus) + .Include(j => j.JobPriority) + .Include(j => j.AssignedUser) + .Include(j => j.OvenCost) + .Include(j => j.JobItems.Where(ji => !ji.IsDeleted)) + .ThenInclude(ji => ji.Coats) + .ThenInclude(c => c.InventoryItem) + .Include(j => j.JobItems.Where(ji => !ji.IsDeleted)) + .ThenInclude(ji => ji.Coats) + .ThenInclude(c => c.Vendor) + .Include(j => j.JobItems.Where(ji => !ji.IsDeleted)) + .ThenInclude(ji => ji.PrepServices) + .ThenInclude(ps => ps.PrepService) + .Include(j => j.JobPrepServices.Where(jps => !jps.IsDeleted)) + .ThenInclude(jps => jps.PrepService) + .FirstOrDefaultAsync(); + } + + /// + public async Task LoadForStatusChangeAsync(int id) + { + return await _context.Jobs + .Include(j => j.JobStatus) + .FirstOrDefaultAsync(j => j.Id == id && !j.IsDeleted); + } + + /// + public async Task> GetChangeHistoryAsync(int jobId) + { + return await _context.JobChangeHistories + .Where(h => h.JobId == jobId && !h.IsDeleted) + .Include(h => h.ChangedBy) + .OrderByDescending(h => h.ChangedAt) + .AsNoTracking() + .ToListAsync(); + } + + /// + public async Task GetLastJobNumberByPrefixAsync(int companyId, string prefix) + { + return await _context.Jobs + .IgnoreQueryFilters() + .Where(j => j.CompanyId == companyId && j.JobNumber.StartsWith(prefix)) + .OrderByDescending(j => j.JobNumber) + .Select(j => j.JobNumber) + .FirstOrDefaultAsync(); + } + + /// + public async Task GetOrphanedConversionJobAsync(int quoteId, int companyId) + { + return await _context.Jobs + .IgnoreQueryFilters() + .FirstOrDefaultAsync(j => j.QuoteId == quoteId && j.CompanyId == companyId); + } + + /// + public async Task> GetScheduledJobsForDateAsync(DateTime date, string? userId = null) + { + var query = _context.Jobs + .Include(j => j.Customer) + .Include(j => j.JobStatus) + .Include(j => j.JobPriority) + .Include(j => j.AssignedUser) + .Include(j => j.JobItems.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.Coats) + .Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == date.Date + && !j.IsDeleted && !j.JobStatus.IsTerminalStatus); + + if (!string.IsNullOrEmpty(userId)) + query = query.Where(j => j.AssignedUserId == userId); + + return await query.ToListAsync(); + } + + /// + public async Task> GetActiveJobsForMobileAsync(int companyId, string? workerId = null) + { + var query = _context.Jobs + .Include(j => j.Customer) + .Include(j => j.JobStatus) + .Include(j => j.JobPriority) + .Include(j => j.AssignedUser) + .Include(j => j.JobItems.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.Coats) + .Where(j => j.CompanyId == companyId && !j.IsDeleted && !j.JobStatus.IsTerminalStatus + && j.JobStatus.StatusCode != "ON_HOLD" && j.JobStatus.StatusCode != "CANCELLED"); + + if (!string.IsNullOrEmpty(workerId)) + query = query.Where(j => j.AssignedUserId == workerId); + + return await query.ToListAsync(); + } + + /// + public async Task LoadForCostingAsync(int jobId, int companyId) + { + return await _context.Jobs + .Where(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted) + .Include(j => j.OvenCost) + .Include(j => j.Invoice) + .Include(j => j.JobItems.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) + .Include(j => j.TimeEntries.Where(t => !t.IsDeleted)) + .ThenInclude(t => t.Worker) + .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 new file mode 100644 index 0000000..d6fd49e --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs @@ -0,0 +1,129 @@ +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 provides IgnoreQueryFilters-based +/// lookups by entity FK (InvoiceId, QuoteId, JobId). +/// +public class NotificationLogRepository : Repository, INotificationLogRepository +{ + public NotificationLogRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task GetLatestForInvoiceAsync(int invoiceId) => + await _context.Set() + .IgnoreQueryFilters() + .Where(n => n.InvoiceId == invoiceId) + .OrderByDescending(n => n.SentAt) + .FirstOrDefaultAsync(); + + /// + public async Task> GetAllForInvoiceAsync(int invoiceId) => + await _context.Set() + .IgnoreQueryFilters() + .Where(n => n.InvoiceId == invoiceId) + .OrderByDescending(n => n.SentAt) + .ToListAsync(); + + /// + public async Task GetLatestForQuoteAsync(int quoteId) => + await _context.Set() + .IgnoreQueryFilters() + .Where(n => n.QuoteId == quoteId) + .OrderByDescending(n => n.SentAt) + .FirstOrDefaultAsync(); + + /// + public async Task> GetAllForQuoteAsync(int quoteId) => + await _context.Set() + .IgnoreQueryFilters() + .Where(n => n.QuoteId == quoteId) + .OrderByDescending(n => n.SentAt) + .ToListAsync(); + + /// + public async Task GetLatestForJobAsync(int jobId) => + await _context.Set() + .IgnoreQueryFilters() + .Where(n => n.JobId == jobId) + .OrderByDescending(n => n.SentAt) + .FirstOrDefaultAsync(); + + /// + public async Task> GetAllForJobAsync(int jobId) => + await _context.Set() + .IgnoreQueryFilters() + .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/PurchaseOrderRepository.cs b/src/PowderCoating.Infrastructure/Repositories/PurchaseOrderRepository.cs new file mode 100644 index 0000000..d44522a --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/PurchaseOrderRepository.cs @@ -0,0 +1,117 @@ +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 provides domain-specific queries +/// previously expressed inline in PurchaseOrdersController. +/// +public class PurchaseOrderRepository : Repository, IPurchaseOrderRepository +{ + public PurchaseOrderRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task LoadForViewAsync(int id, int companyId) + { + return await _context.Set() + .Where(p => p.Id == id && !p.IsDeleted && p.CompanyId == companyId) + .Include(p => p.Vendor) + .Include(p => p.Bill) + .Include(p => p.Items.Where(i => !i.IsDeleted)) + .ThenInclude(i => i.InventoryItem) + .FirstOrDefaultAsync(); + } + + /// + public async Task GetStatsAsync(int companyId) + { + var today = DateTime.UtcNow.Date; + var items = await _context.Set() + .Where(po => !po.IsDeleted && po.CompanyId == companyId) + .Select(po => new { po.Status, po.TotalAmount, po.ExpectedDeliveryDate }) + .ToListAsync(); + + return new PurchaseOrderStats( + TotalCount: items.Count, + OpenCount: items.Count(p => + p.Status == PurchaseOrderStatus.Draft || + p.Status == PurchaseOrderStatus.Submitted || + p.Status == PurchaseOrderStatus.PartiallyReceived), + CommittedValue: items.Where(p => p.Status != PurchaseOrderStatus.Cancelled).Sum(p => p.TotalAmount), + OverdueCount: items.Count(p => + (p.Status == PurchaseOrderStatus.Draft || p.Status == PurchaseOrderStatus.Submitted || p.Status == PurchaseOrderStatus.PartiallyReceived) && + p.ExpectedDeliveryDate.HasValue && + p.ExpectedDeliveryDate.Value.Date < today) + ); + } + + /// + public async Task<(List Items, int TotalCount)> GetPagedAsync( + int companyId, + int pageNumber, + int pageSize, + PurchaseOrderStatus? statusFilter = null, + int? vendorId = null, + DateTime? dateFrom = null, + DateTime? dateTo = null, + string? searchTerm = null, + string? sortColumn = null, + string? sortDirection = null) + { + var query = _context.Set() + .Include(po => po.Vendor) + .Include(po => po.Items.Where(i => !i.IsDeleted)) + .Where(po => !po.IsDeleted && po.CompanyId == companyId) + .AsQueryable(); + + if (statusFilter.HasValue) + query = query.Where(po => po.Status == statusFilter.Value); + + if (vendorId.HasValue) + query = query.Where(po => po.VendorId == vendorId.Value); + + if (dateFrom.HasValue) + query = query.Where(po => po.OrderDate >= dateFrom.Value); + + if (dateTo.HasValue) + query = query.Where(po => po.OrderDate <= dateTo.Value.AddDays(1)); + + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + var term = searchTerm.Trim().ToLower(); + query = query.Where(po => + po.PoNumber.ToLower().Contains(term) || + po.Vendor.CompanyName.ToLower().Contains(term) || + (po.Notes != null && po.Notes.ToLower().Contains(term))); + } + + query = (sortColumn?.ToLower(), sortDirection?.ToLower()) switch + { + ("ponumber", "asc") => query.OrderBy(po => po.PoNumber), + ("ponumber", _) => query.OrderByDescending(po => po.PoNumber), + ("vendor", "asc") => query.OrderBy(po => po.Vendor.CompanyName), + ("vendor", _) => query.OrderByDescending(po => po.Vendor.CompanyName), + ("status", "asc") => query.OrderBy(po => po.Status), + ("status", _) => query.OrderByDescending(po => po.Status), + ("orderdate", "asc") => query.OrderBy(po => po.OrderDate), + ("orderdate", _) => query.OrderByDescending(po => po.OrderDate), + ("expected", "asc") => query.OrderBy(po => po.ExpectedDeliveryDate), + ("expected", _) => query.OrderByDescending(po => po.ExpectedDeliveryDate), + ("total", "asc") => query.OrderBy(po => po.TotalAmount), + ("total", _) => query.OrderByDescending(po => po.TotalAmount), + _ => query.OrderByDescending(po => po.OrderDate) + }; + + var totalCount = await query.CountAsync(); + var items = await query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return (items, totalCount); + } +} diff --git a/src/PowderCoating.Infrastructure/Repositories/QuoteRepository.cs b/src/PowderCoating.Infrastructure/Repositories/QuoteRepository.cs new file mode 100644 index 0000000..445a463 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Repositories/QuoteRepository.cs @@ -0,0 +1,111 @@ +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 queries previously +/// scattered as inline EF expressions across QuotesController and +/// QuoteApprovalController. +/// +public class QuoteRepository : Repository, IQuoteRepository +{ + public QuoteRepository(ApplicationDbContext context) : base(context) { } + + /// + public async Task LoadForDetailsAsync(int id) + { + var quote = await _context.Quotes + .Where(q => q.Id == id && !q.IsDeleted) + .Include(q => q.Customer) + .Include(q => q.PreparedBy) + .Include(q => q.QuoteStatus) + .Include(q => q.OvenCost) + .Include(q => q.QuotePrepServices.Where(qps => !qps.IsDeleted)) + .ThenInclude(qps => qps.PrepService) + .FirstOrDefaultAsync(); + + if (quote == null) return null; + + // QuoteItems with nested coats and prep services loaded separately to avoid + // cartesian explosion from multiple collection includes in a single query. + quote.QuoteItems = await _context.QuoteItems + .Where(qi => qi.QuoteId == id && !qi.IsDeleted) + .Include(qi => qi.Coats) + .ThenInclude(c => c.InventoryItem) + .Include(qi => qi.Coats) + .ThenInclude(c => c.Vendor) + .Include(qi => qi.CatalogItem) + .Include(qi => qi.PrepServices) + .ThenInclude(ps => ps.PrepService) + .ToListAsync(); + + return quote; + } + + /// + public async Task GetByApprovalTokenAsync(string token) + { + // IgnoreQueryFilters: approval portal is unauthenticated — no tenant context on the request. + return await _context.Quotes + .IgnoreQueryFilters() + .Include(q => q.Customer) + .Include(q => q.QuoteStatus) + .Include(q => q.QuoteItems.Where(qi => !qi.IsDeleted)) + .FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted); + } + + /// + public async Task> GetChangeHistoryAsync(int quoteId) + { + return await _context.QuoteChangeHistories + .Where(h => h.QuoteId == quoteId && !h.IsDeleted) + .Include(h => h.ChangedBy) + .OrderByDescending(h => h.ChangedAt) + .AsNoTracking() + .ToListAsync(); + } + + /// + public async Task GetIndexStatsAsync(List openStatusIds, List approvedConvertedStatusIds) + { + var stats = await _context.Quotes + .Where(q => !q.IsDeleted) + .Select(q => new { q.QuoteStatusId, q.Total }) + .ToListAsync(); + + return new QuoteIndexStats( + OpenCount: stats.Count(q => openStatusIds.Contains(q.QuoteStatusId)), + ApprovedConvertedCount: stats.Count(q => approvedConvertedStatusIds.Contains(q.QuoteStatusId)), + TotalValue: stats.Sum(q => q.Total)); + } + + /// + public async Task> GetItemsWithCoatsAsync(int quoteId) + { + return await _context.QuoteItems + .Where(qi => qi.QuoteId == quoteId && !qi.IsDeleted) + .Include(qi => qi.Coats) + .ThenInclude(c => c.InventoryItem) + .Include(qi => qi.Coats) + .ThenInclude(c => c.Vendor) + .Include(qi => qi.CatalogItem) + .Include(qi => qi.PrepServices) + .ThenInclude(ps => ps.PrepService) + .AsNoTracking() + .ToListAsync(); + } + + /// + public async Task GetLastQuoteNumberByPrefixAsync(int companyId, string prefix) + { + return await _context.Quotes + .IgnoreQueryFilters() + .Where(q => q.CompanyId == companyId && q.QuoteNumber.StartsWith(prefix)) + .OrderByDescending(q => q.QuoteNumber) + .Select(q => q.QuoteNumber) + .FirstOrDefaultAsync(); + } +} diff --git a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs index 9b4bdde..d20d2e1 100644 --- a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs +++ b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; +using PowderCoating.Core.Interfaces.Repositories; using PowderCoating.Infrastructure.Data; namespace PowderCoating.Infrastructure.Repositories; @@ -40,29 +41,31 @@ public class UnitOfWork : IUnitOfWork private IRepository? _aiItemPredictions; // Powder Insights - private IRepository? _powderUsageLogs; + private IPowderUsageLogRepository? _powderUsageLogs; // Core repositories - private IRepository? _customers; - private IRepository? _jobs; + 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? _quotes; + private IRepository? _jobPrepServices; + private IQuoteRepository? _quotes; private IRepository? _quotePhotos; private IRepository? _quoteItems; private IRepository? _quoteItemCoats; 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; @@ -87,20 +90,28 @@ public class UnitOfWork : IUnitOfWork private IRepository? _catalogPriceCheckReports; // Notifications - private IRepository? _notificationLogs; + private INotificationLogRepository? _notificationLogs; private IRepository? _notificationTemplates; // Subscription 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; @@ -109,7 +120,7 @@ public class UnitOfWork : IUnitOfWork private IRepository? _giftCertificateRedemptions; // Purchase Orders - private IRepository? _purchaseOrders; + private IPurchaseOrderRepository? _purchaseOrders; private IRepository? _purchaseOrderItems; // Oven Scheduling @@ -117,14 +128,14 @@ public class UnitOfWork : IUnitOfWork private IRepository? _ovenBatchItems; // Invoices, Payments & Deposits - private IRepository? _invoices; + private IInvoiceRepository? _invoices; private IRepository? _invoiceItems; private IRepository? _payments; private IRepository? _deposits; // Expense Tracking / Accounts Payable private IRepository? _accounts; - private IRepository? _bills; + private IBillRepository? _bills; private IRepository? _billLineItems; private IRepository? _billPayments; private IRepository? _expenses; @@ -166,17 +177,17 @@ 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. - public IRepository Customers => - _customers ??= new Repository(_context); + public ICustomerRepository Customers => + _customers ??= new CustomerRepository(_context); /// Repository for records progressing through the 16-status lifecycle; tenant-filtered with soft delete. - public IRepository Jobs => - _jobs ??= new Repository(_context); + public IJobRepository Jobs => + _jobs ??= new JobRepository(_context); /// Repository for overrides that let supervisors re-order the shop floor queue. public IRepository JobDailyPriorities => @@ -187,16 +198,22 @@ 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); /// Repository for audit entries; tenant-filtered with soft delete. public IRepository JobChangeHistories => _jobChangeHistories ??= new Repository(_context); + /// Repository for job-level prep service assignments; tenant-filtered with soft delete. + public IRepository JobPrepServices => + _jobPrepServices ??= new Repository(_context); + /// Repository for records with multi-item pricing; tenant-filtered with soft delete. - public IRepository Quotes => - _quotes ??= new Repository(_context); + public IQuoteRepository Quotes => + _quotes ??= new QuoteRepository(_context); /// Repository for AI photo uploads; tenant-filtered with soft delete. public IRepository QuotePhotos => @@ -223,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 => @@ -247,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 => @@ -350,9 +367,9 @@ public class UnitOfWork : IUnitOfWork _catalogPriceCheckReports ??= new Repository(_context); // Notifications - /// Repository for outbound notification audit records; tenant-filtered with soft delete. - public IRepository NotificationLogs => - _notificationLogs ??= new Repository(_context); + /// Repository for outbound notification audit records; provides IgnoreQueryFilters lookups by InvoiceId, QuoteId, and JobId for notification history panels. + public INotificationLogRepository NotificationLogs => + _notificationLogs ??= new NotificationLogRepository(_context); /// Repository for per-company channel template overrides; unique on (CompanyId, Type, Channel). public IRepository NotificationTemplates => @@ -363,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 => @@ -388,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 => @@ -405,8 +446,8 @@ public class UnitOfWork : IUnitOfWork // Purchase Orders /// Repository for vendor purchase orders; tenant-filtered with soft delete. - public IRepository PurchaseOrders => - _purchaseOrders ??= new Repository(_context); + public IPurchaseOrderRepository PurchaseOrders => + _purchaseOrders ??= new PurchaseOrderRepository(_context); /// Repository for line-items on a purchase order; cascade-deleted with the PO. public IRepository PurchaseOrderItems => @@ -423,8 +464,8 @@ public class UnitOfWork : IUnitOfWork // Invoices, Payments & Deposits /// Repository for customer invoices (1:1 with Job); tenant-filtered with soft delete. - public IRepository Invoices => - _invoices ??= new Repository(_context); + public IInvoiceRepository Invoices => + _invoices ??= new InvoiceRepository(_context); /// Repository for line-items on an invoice; tenant-filtered with soft delete. public IRepository InvoiceItems => @@ -447,8 +488,8 @@ public class UnitOfWork : IUnitOfWork _accounts ??= new Repository(_context); /// Repository for vendor bills (accounts payable); tenant-filtered with soft delete. - public IRepository Bills => - _bills ??= new Repository(_context); + public IBillRepository Bills => + _bills ??= new BillRepository(_context); /// Repository for expense line-items on a vendor bill; each assigned to a chart-of-accounts entry. public IRepository BillLineItems => 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 new file mode 100644 index 0000000..6563c80 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs @@ -0,0 +1,439 @@ +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 (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 +{ + private readonly ApplicationDbContext _context; + + public FinancialReportService(ApplicationDbContext context) + { + _context = context; + } + + /// + 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 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 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 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 new file mode 100644 index 0000000..4d8bea5 --- /dev/null +++ b/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs @@ -0,0 +1,145 @@ +using Microsoft.EntityFrameworkCore; +using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; + +namespace PowderCoating.Infrastructure.Services; + +/// +/// Implements operational 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. +/// See docs/DATA_ACCESS_ARCHITECTURE.md for the full migration plan. +/// +public class OperationalReportService : IOperationalReportService +{ + private readonly ApplicationDbContext _context; + + public OperationalReportService(ApplicationDbContext context) + { + _context = context; + } + + /// + 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 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.Shared/Constants/AppConstants.cs b/src/PowderCoating.Shared/Constants/AppConstants.cs index ee20f92..e425dca 100644 --- a/src/PowderCoating.Shared/Constants/AppConstants.cs +++ b/src/PowderCoating.Shared/Constants/AppConstants.cs @@ -109,10 +109,23 @@ public static class AppConstants public const string CurrentTosVersion = "2026-04-09"; } + public static class GuidedActivation + { + public const string QuoteFirstPath = "quote_first"; + public const string JobFirstPath = "job_first"; + + public const string QueryKey = "guidedActivation"; + public const string HighlightJobIdKey = "highlightJobId"; + public const string QuoteCreatedStep = "quote_created"; + public const string JobCreatedStep = "job_created"; + public const string BoardIntroStep = "board_intro"; + public const string BoardReadyForInvoiceStep = "board_ready_for_invoice"; + public const string InvoiceCreatedStep = "invoice_created"; + } + public static class PowderInsights { public const int Layer3MinJobs = 150; // Minimum jobs with actual powder data before Layer 3 predictive features unlock public const int Layer2MinJobs = 10; // Minimum for efficiency trending to be meaningful } } - 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/BillsController.cs b/src/PowderCoating.Web/Controllers/BillsController.cs index ca7d38a..8d66b2d 100644 --- a/src/PowderCoating.Web/Controllers/BillsController.cs +++ b/src/PowderCoating.Web/Controllers/BillsController.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; using PowderCoating.Application.DTOs.PurchaseOrder; namespace PowderCoating.Web.Controllers; @@ -29,7 +28,6 @@ public class BillsController : Controller private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly ApplicationDbContext _context; private readonly IAccountBalanceService _accountBalanceService; private readonly IAccountingAiService _accountingAi; private readonly IAzureBlobStorageService _blobStorage; @@ -41,7 +39,6 @@ public class BillsController : Controller IMapper mapper, UserManager userManager, ILogger logger, - ApplicationDbContext context, IAccountBalanceService accountBalanceService, IAccountingAiService accountingAi, IAzureBlobStorageService blobStorage, @@ -52,7 +49,6 @@ public class BillsController : Controller _mapper = mapper; _userManager = userManager; _logger = logger; - _context = context; _accountBalanceService = accountBalanceService; _accountingAi = accountingAi; _blobStorage = blobStorage; @@ -86,23 +82,7 @@ public class BillsController : Controller // Bills if (type == null || type == "Bill") { - var bills = await _context.Bills - .Include(b => b.Vendor) - .Where(b => !b.IsDeleted) - .Where(b => status != "Unpaid" || - (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid)) - .Where(b => status != "Overdue" || - (b.Status != BillStatus.Paid && b.Status != BillStatus.Voided && - b.DueDate.HasValue && b.DueDate.Value.Date < DateTime.Today)) - .Where(b => string.IsNullOrEmpty(search) || - b.BillNumber.Contains(search) || - b.Vendor.CompanyName.Contains(search) || - (b.VendorInvoiceNumber != null && b.VendorInvoiceNumber.Contains(search)) || - (b.Memo != null && b.Memo.Contains(search)) || - b.LineItems.Any(li => li.Description.Contains(search)) || - (searchAmount.HasValue && (b.Total == searchAmount.Value || b.AmountPaid == searchAmount.Value))) - .OrderByDescending(b => b.BillDate) - .ToListAsync(); + var bills = await _unitOfWork.Bills.GetForIndexAsync(status, search, searchAmount); entries.AddRange(bills.Select(b => new BillExpenseListDto { @@ -133,17 +113,17 @@ public class BillsController : Controller // Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue") { - var expenses = await _context.Set() - .Include(e => e.Vendor) - .Include(e => e.ExpenseAccount) - .Where(e => !e.IsDeleted) - .Where(e => string.IsNullOrEmpty(search) || - e.ExpenseNumber.Contains(search) || - (e.Vendor != null && e.Vendor.CompanyName.Contains(search)) || - (e.Memo != null && e.Memo.Contains(search)) || - (searchAmount.HasValue && e.Amount == searchAmount.Value)) - .OrderByDescending(e => e.Date) - .ToListAsync(); + var expSearch = search; + var expAmount = searchAmount; + var expenseList = await _unitOfWork.Expenses.FindAsync( + e => string.IsNullOrEmpty(expSearch) || + e.ExpenseNumber.Contains(expSearch) || + (e.Vendor != null && e.Vendor.CompanyName.Contains(expSearch)) || + (e.Memo != null && e.Memo.Contains(expSearch)) || + (expAmount.HasValue && e.Amount == expAmount.Value), + false, + e => e.Vendor!, e => e.ExpenseAccount!); + var expenses = expenseList.OrderByDescending(e => e.Date).ToList(); entries.AddRange(expenses.Select(e => new BillExpenseListDto { @@ -198,11 +178,7 @@ public class BillsController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Vendor) - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.InventoryItem) - .FirstOrDefaultAsync(p => p.Id == purchaseOrderId && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(purchaseOrderId, currentUser.CompanyId); if (po == null) return NotFound(); @@ -218,20 +194,16 @@ public class BillsController : Controller return RedirectToAction(nameof(Details), new { id = po.BillId }); } - var apAccount = await _context.Accounts - .Where(a => !a.IsDeleted && a.AccountSubType == AccountSubType.AccountsPayable) - .OrderBy(a => a.AccountNumber) - .FirstOrDefaultAsync(); + var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.AccountSubType == AccountSubType.AccountsPayable); // Vendor default expense account, fall back to first expense/COGS account int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId; if (!defaultExpenseAccountId.HasValue) { - defaultExpenseAccountId = (await _context.Accounts - .Where(a => !a.IsDeleted && a.IsActive && - (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)) - .OrderBy(a => a.AccountNumber) - .FirstOrDefaultAsync())?.Id; + var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)); + defaultExpenseAccountId = fallbackAccount?.Id; } var lineItems = po.Items @@ -293,10 +265,8 @@ public class BillsController : Controller }; // Pre-fill AP account - var apAccount = await _context.Accounts - .Where(a => !a.IsDeleted && a.AccountSubType == AccountSubType.AccountsPayable) - .OrderBy(a => a.AccountNumber) - .FirstOrDefaultAsync(); + var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.AccountSubType == AccountSubType.AccountsPayable); dto.APAccountId = apAccount?.Id ?? 0; // Pre-fill default expense account for vendor @@ -385,13 +355,12 @@ public class BillsController : Controller // Link bill back to source PO if created from one if (dto.PurchaseOrderId > 0) { - var po = await _context.Set() - .FirstOrDefaultAsync(p => p.Id == dto.PurchaseOrderId && !p.IsDeleted); + var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value); if (po != null) { po.BillId = bill.Id; po.UpdatedAt = DateTime.UtcNow; - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); } } @@ -416,8 +385,8 @@ public class BillsController : Controller bill.AmountPaid = payment.Amount; bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid; - await _context.BillPayments.AddAsync(payment); - await _context.SaveChangesAsync(); + await _unitOfWork.BillPayments.AddAsync(payment); + await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Bill {bill.BillNumber} saved and marked as paid."; } @@ -448,28 +417,18 @@ public class BillsController : Controller { if (id == null) return NotFound(); - var bill = await _context.Bills - .Include(b => b.Vendor) - .Include(b => b.APAccount) - .Include(b => b.LineItems.Where(li => !li.IsDeleted)) - .ThenInclude(li => li.Account) - .Include(b => b.LineItems.Where(li => !li.IsDeleted)) - .ThenInclude(li => li.Job) - .Include(b => b.Payments.Where(p => !p.IsDeleted)) - .ThenInclude(p => p.BankAccount) - .FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted); - + var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value); if (bill == null) return NotFound(); var dto = _mapper.Map(bill); // Payment form defaults - var bankAccounts = await _context.Accounts - .Where(a => !a.IsDeleted && (a.AccountSubType == AccountSubType.Checking || - a.AccountSubType == AccountSubType.Savings || - a.AccountSubType == AccountSubType.CreditCard)) + var bankAccounts = (await _unitOfWork.Accounts.FindAsync( + a => a.AccountSubType == AccountSubType.Checking || + a.AccountSubType == AccountSubType.Savings || + a.AccountSubType == AccountSubType.CreditCard)) .OrderBy(a => a.AccountNumber) - .ToListAsync(); + .ToList(); ViewBag.BankAccounts = bankAccounts .Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())) @@ -495,10 +454,7 @@ public class BillsController : Controller { if (id == null) return NotFound(); - var bill = await _context.Bills - .Include(b => b.LineItems.Where(li => !li.IsDeleted)) - .FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted); - + var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value); if (bill == null) return NotFound(); if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided) @@ -561,9 +517,7 @@ public class BillsController : Controller try { - var bill = await _context.Bills - .Include(b => b.LineItems) - .FirstOrDefaultAsync(b => b.Id == id && !b.IsDeleted); + var bill = await _unitOfWork.Bills.LoadForEditAsync(id); if (bill == null) return NotFound(); @@ -611,7 +565,7 @@ public class BillsController : Controller bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2); bill.Total = bill.SubTotal + bill.TaxAmount; - await _context.BillLineItems.AddRangeAsync(newLineItems); + await _unitOfWork.BillLineItems.AddRangeAsync(newLineItems); // Handle receipt file replacement if (receiptFile != null && receiptFile.Length > 0) @@ -628,7 +582,7 @@ public class BillsController : Controller } } - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Bill {bill.BillNumber} updated."; return RedirectToAction(nameof(Details), new { id }); @@ -1024,12 +978,7 @@ public class BillsController : Controller private async Task GenerateBillNumberAsync() { var prefix = $"BILL-{DateTime.Now:yyMM}-"; - var last = await _context.Bills - .IgnoreQueryFilters() - .Where(b => b.BillNumber.StartsWith(prefix)) - .OrderByDescending(b => b.BillNumber) - .Select(b => b.BillNumber) - .FirstOrDefaultAsync(); + var last = await _unitOfWork.Bills.GetLastBillNumberAsync(prefix); int next = 1; if (last != null && int.TryParse(last[prefix.Length..], out int num)) @@ -1046,12 +995,7 @@ public class BillsController : Controller private async Task GeneratePaymentNumberAsync() { var prefix = $"BPMT-{DateTime.Now:yyMM}-"; - var last = await _context.BillPayments - .IgnoreQueryFilters() - .Where(p => p.PaymentNumber.StartsWith(prefix)) - .OrderByDescending(p => p.PaymentNumber) - .Select(p => p.PaymentNumber) - .FirstOrDefaultAsync(); + var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(prefix); int next = 1; if (last != null && int.TryParse(last[prefix.Length..], out int num)) 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/CustomersController.cs b/src/PowderCoating.Web/Controllers/CustomersController.cs index 433879f..c1d3b85 100644 --- a/src/PowderCoating.Web/Controllers/CustomersController.cs +++ b/src/PowderCoating.Web/Controllers/CustomersController.cs @@ -25,7 +25,6 @@ public class CustomersController : Controller private readonly INotificationService _notificationService; private readonly ISubscriptionService _subscriptionService; private readonly ITenantContext _tenantContext; - private readonly ApplicationDbContext _context; private readonly UserManager _userManager; public CustomersController( @@ -35,7 +34,6 @@ public class CustomersController : Controller INotificationService notificationService, ISubscriptionService subscriptionService, ITenantContext tenantContext, - ApplicationDbContext context, UserManager userManager) { _unitOfWork = unitOfWork; @@ -44,7 +42,6 @@ public class CustomersController : Controller _notificationService = notificationService; _subscriptionService = subscriptionService; _tenantContext = tenantContext; - _context = context; _userManager = userManager; } @@ -555,11 +552,9 @@ public class CustomersController : Controller try { await _notificationService.NotifySmsConsentGrantedAsync(customer); } catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); } - var smsLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.CustomerId == customer.Id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var logs = await _unitOfWork.NotificationLogs.FindAsync( + n => n.CustomerId == customer.Id, ignoreQueryFilters: true); + var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault(); this.SetNotificationResultToast(smsLog); } @@ -679,11 +674,9 @@ public class CustomersController : Controller try { await _notificationService.NotifySmsConsentGrantedAsync(customer); } catch (Exception ex) { _logger.LogWarning(ex, "SMS consent confirmation failed for customer {Id}", customer.Id); } - var smsLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.CustomerId == customer.Id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var logs = await _unitOfWork.NotificationLogs.FindAsync( + n => n.CustomerId == customer.Id, ignoreQueryFilters: true); + var smsLog = logs.OrderByDescending(n => n.SentAt).FirstOrDefault(); this.SetNotificationResultToast(smsLog); } diff --git a/src/PowderCoating.Web/Controllers/DashboardController.cs b/src/PowderCoating.Web/Controllers/DashboardController.cs index 902d1dd..a353c23 100644 --- a/src/PowderCoating.Web/Controllers/DashboardController.cs +++ b/src/PowderCoating.Web/Controllers/DashboardController.cs @@ -1,13 +1,16 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; 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 PowderCoating.Web.ViewModels.Dashboard; +using PowderCoating.Web.ViewModels.GuidedActivation; using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus; namespace PowderCoating.Web.Controllers; @@ -17,7 +20,11 @@ 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 readonly UserManager _userManager; + private readonly ISubscriptionService _subscriptionService; private static readonly string[] CompletedStatusCodes = [ @@ -39,16 +46,22 @@ 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, + UserManager userManager, + ISubscriptionService subscriptionService) { _unitOfWork = unitOfWork; _logger = logger; - _context = context; + _dashboardRead = dashboardRead; _tenantContext = tenantContext; _configHealth = configHealth; + _userManager = userManager; + _subscriptionService = subscriptionService; } /// @@ -66,23 +79,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 +97,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 +107,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 +115,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 +137,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 +157,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 +173,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 +195,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 +219,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 +252,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 +270,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 +285,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 +335,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 +351,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 +420,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 +488,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 +506,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 +552,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,18 +571,20 @@ 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) + { ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value); + // Load prefs once and share between both banner and progress widget builders + var companyPrefs = await _unitOfWork.CompanyPreferences + .FirstOrDefaultAsync(p => p.CompanyId == currentCompanyId.Value && !p.IsDeleted); + + ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs); + ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs); + } + return View(vm); } catch (Exception ex) @@ -705,11 +607,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 +620,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; @@ -757,13 +655,132 @@ public class DashboardController : Controller } } + private GuidedActivationBannerViewModel? BuildGuidedActivationBanner(CompanyPreferences? prefs) + { + var companyRole = User.FindFirst("CompanyRole")?.Value; + if (companyRole != AppConstants.CompanyRoles.CompanyAdmin) + return null; + + if (prefs == null || !prefs.SetupWizardCompleted || prefs.FirstWorkflowCompleted) + return null; + + return new GuidedActivationBannerViewModel + { + Show = true, + IsDismissed = prefs.GuidedActivationDismissedAt.HasValue, + Title = prefs.GuidedActivationDismissedAt.HasValue + ? "Start your first workflow when you're ready" + : "Create your first job or quote", + Message = prefs.GuidedActivationDismissedAt.HasValue + ? "You can come back anytime to run a short walkthrough using real quotes, jobs, and invoices." + : "Run a quick 2-minute workflow to see how the system works.", + ActionText = "Start first workflow" + }; + } + + /// + /// Builds the "Get the most out of your shop" activation checklist for CompanyAdmins. + /// Returns null when the wizard is not yet complete, the viewer is not a CompanyAdmin, + /// or all six tasks are already done (so the widget disappears naturally at 100%). + /// Three DB checks are fired in parallel to keep the overhead to a minimum. + /// + private async Task BuildShopProgressWidgetAsync(int companyId, CompanyPreferences? prefs) + { + var companyRole = User.FindFirst("CompanyRole")?.Value; + if (companyRole != AppConstants.CompanyRoles.CompanyAdmin) + return null; + + if (prefs == null || !prefs.SetupWizardCompleted) + return null; + + // These share the same scoped DbContext so must run sequentially + var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(_ => true); + // ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible + var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync( + j => j.CompanyId == companyId && j.UpdatedAt != null, + ignoreQueryFilters: true); + var teamCount = await _userManager.Users + .CountAsync(u => u.CompanyId == companyId && u.IsActive && !u.IsBanned); + var (_, maxUsers) = await _subscriptionService.GetUserCountAsync(companyId); + var planAllowsMultipleUsers = maxUsers != 1; + + var items = new List + { + new() + { + Done = prefs.FirstJobCreatedAt.HasValue || prefs.FirstQuoteCreatedAt.HasValue, + Label = "Create your first job or quote", + SubLabel = "Get customer sign-off before you start — takes about 2 minutes.", + DoneSubLabel = "Your first job is now being tracked.", + Icon = "bi-file-earmark-plus", + CtaText = "Create a quote", + CtaUrl = Url.Action("Start", "GuidedActivation")! + }, + new() + { + Done = hasStatusHistory, + Label = "Move a job through your workflow", + SubLabel = "Move a job through your board so your crew always knows what's next.", + DoneSubLabel = "You've started tracking work through your shop.", + Icon = "bi-arrow-right-circle", + CtaText = "Go to jobs board", + CtaUrl = Url.Action("Board", "Jobs")! + }, + new() + { + Done = prefs.FirstInvoiceCreatedAt.HasValue, + Label = "Send your first invoice", + SubLabel = "When the work is done, turn it into an invoice and send it in seconds.", + DoneSubLabel = "You're ready to get paid.", + Icon = "bi-receipt", + CtaText = "Create invoice", + CtaUrl = Url.Action("Create", "Invoices")! + }, + planAllowsMultipleUsers ? new() + { + Done = teamCount > 1, + Label = "Bring your crew in", + SubLabel = "Add your crew so everyone stays on the same page in real time.", + DoneSubLabel = "Your team is in the system.", + Icon = "bi-people", + CtaText = "Invite team", + CtaUrl = Url.Action("Index", "CompanyUsers")! + } : null!, + new() + { + Done = hasCustomizedLookups, + Label = "Customize your workflow", + SubLabel = "Adjust stages and services to match how your shop runs.", + DoneSubLabel = "Your workflow speaks your shop's language.", + Icon = "bi-list-ul", + CtaText = "Customize workflow", + CtaUrl = Url.Action("Index", "CompanySettings") + "#data-lookups" + }, + new() + { + Done = prefs.DefaultPaymentTerms != "Net 30" + || prefs.DefaultQuoteValidityDays != 30 + || prefs.DefaultTurnaroundDays != 7 + || prefs.QtDefaultTerms != null, + Label = "Set how you get paid", + SubLabel = "Set your payment terms and timing so every job goes out right.", + DoneSubLabel = "Your payment defaults are locked in.", + Icon = "bi-file-earmark-text", + CtaText = "Set payment terms", + CtaUrl = Url.Action("Index", "CompanySettings") + "#general" + } + }; + + return new ShopProgressWidgetViewModel { Items = items.Where(i => i != null).Select(i => i!).ToList() }; + } + /// /// 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 +788,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 +797,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 +825,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 +874,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 +887,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 +932,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 +950,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 +960,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 +973,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 +984,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 +1013,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 +1025,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 +1061,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 b66570b..22ca896 100644 --- a/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs +++ b/src/PowderCoating.Web/Controllers/EmailBroadcastController.cs @@ -1,207 +1,598 @@ +using System.ComponentModel.DataAnnotations; +using System.Net; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; -/// -/// SuperAdmin-only tool for sending platform-wide broadcast emails to tenant -/// company contacts. Emails are sent one at a time via -/// rather than bulk API because each message requires a personalised unsubscribe link -/// containing the company's unique MarketingUnsubscribeToken. -/// +// 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 { + private static readonly Regex ScriptRegex = new( + @"<\s*(script|style|iframe|object|embed|form|input|button|textarea|select|meta|link)\b[^>]*>.*?<\s*/\s*\1\s*>", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex CommentRegex = new( + @"", + RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex TagRegex = new( + @"<\s*(/?)\s*([a-z0-9]+)([^>]*)>", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex HrefRegex = new( + @"href\s*=\s*(['""]?)([^'"">\s]+)\1", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly HashSet AllowedTags = + [ + "a", "p", "br", "strong", "b", "em", "i", "u", + "ul", "ol", "li", "blockquote", "h1", "h2", "h3", "h4" + ]; + private readonly ApplicationDbContext _db; private readonly IEmailService _emailService; + private readonly IPlatformSettingsService _platformSettings; private readonly ILogger _logger; public EmailBroadcastController( ApplicationDbContext db, IEmailService emailService, + IPlatformSettingsService platformSettings, ILogger logger) { _db = db; _emailService = emailService; + _platformSettings = platformSettings; _logger = logger; } - /// - /// Renders the broadcast compose form, pre-populating ViewBag with plan configs - /// and the active company list so the targeting dropdowns are populated without - /// a separate AJAX call. - /// - public async Task Index() - { - await PopulateViewBag(); - return View(new BroadcastForm()); - } - - /// Returns JSON count of recipients for the current filter — used for the live preview. [HttpGet] - public async Task RecipientCount(string target, string? plan, int[]? companyIds) + public IActionResult Index() => View(new AdminEmailComposeModel()); + + [HttpPost, ValidateAntiForgeryToken] + public async Task SelectCompanies(AdminEmailComposeModel form) { - var recipients = await BuildRecipientListAsync(target, plan, companyIds); - return Json(new { count = recipients.Count }); + NormalizeComposeModel(form); + + if (!ValidateComposeModel(form)) + return View("Index", form); + + var viewModel = await BuildSelectionModelAsync(form); + return View(viewModel); } - /// - /// Sends the composed broadcast email to all recipients matching the chosen - /// targeting criteria. Aborts early (with a validation error) if no recipients - /// are found, to prevent accidental empty sends. - /// - /// Each email body is HTML-encoded (then line-breaks converted to - /// <br>) and wrapped in a branded container that appends a - /// per-company unsubscribe footer. Failures are counted and reported in the - /// success banner rather than aborting the remainder of the batch, so a single - /// bad address does not block delivery to every other recipient. - /// - /// [HttpPost, ValidateAntiForgeryToken] - public async Task Send(BroadcastForm form) + public IActionResult BackToCompose(AdminEmailComposeModel form) { - if (!ModelState.IsValid) + NormalizeComposeModel(form); + return View("Index", form); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Preview(AdminEmailSelectionModel form) + { + NormalizeComposeModel(form); + + if (!ValidateComposeModel(form)) + return View("Index", new AdminEmailComposeModel + { + Subject = form.Subject, + BodyHtml = form.BodyHtml + }); + + if (!ValidateCompanySelection(form)) + return View("SelectCompanies", await BuildSelectionModelAsync(form)); + + var preview = await BuildPreviewModelAsync(form); + return View(preview); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task BackToSelectCompanies(AdminEmailSelectionModel form) + { + NormalizeComposeModel(form); + return View("SelectCompanies", await BuildSelectionModelAsync(form)); + } + + [HttpPost, ValidateAntiForgeryToken] + public async Task Send(AdminEmailSendRequest form) + { + NormalizeComposeModel(form); + + if (!ValidateComposeModel(form)) + return View("Index", new AdminEmailComposeModel + { + Subject = form.Subject, + BodyHtml = form.BodyHtml + }); + + if (!ValidateCompanySelection(form)) + return View("SelectCompanies", await BuildSelectionModelAsync(form)); + + var recipients = await LoadRecipientContextsAsync(form.CompanyIds!); + var replyToEmail = await GetAdminReplyToAsync(); + const string replyToName = "Powder Coating Logix Admin"; + + var sent = 0; + var failed = 0; + var skipped = 0; + + foreach (var recipient in recipients) { - await PopulateViewBag(); - return View("Index", form); - } + var renderedSubject = RenderPlainTemplate(form.Subject, recipient); + var renderedBody = RenderHtmlTemplate(form.BodyHtml, recipient); + var plainTextBody = ConvertHtmlToPlainText(renderedBody); - var recipients = await BuildRecipientListAsync(form.Target, form.PlanFilter, form.CompanyIds); - - if (recipients.Count == 0) - { - TempData["Error"] = "No recipients matched the selected criteria."; - await PopulateViewBag(); - return View("Index", form); - } - - int sent = 0, failed = 0; - var baseUrl = $"{Request.Scheme}://{Request.Host}"; - var encodedBody = System.Net.WebUtility.HtmlEncode(form.Body).Replace("\n", "
"); - - foreach (var (email, name, unsubToken) in recipients) - { - var unsubUrl = $"{baseUrl}/Unsubscribe/BroadcastEmail/{unsubToken}"; - var htmlBody = $@" -
-

{encodedBody}

-
-

- This message was sent by the Powder Coating Logix platform team.
- Unsubscribe from platform announcements -

-
"; + if (string.IsNullOrWhiteSpace(recipient.RecipientEmail)) + { + skipped++; + await WriteLogAsync(new NotificationLog + { + Channel = NotificationChannel.Email, + NotificationType = NotificationType.AdminEmail, + Status = NotificationStatus.Skipped, + RecipientName = recipient.RecipientName, + Recipient = string.Empty, + Subject = renderedSubject, + Message = plainTextBody, + ErrorMessage = "Company primary contact email is not configured.", + SentAt = DateTime.UtcNow, + CompanyId = recipient.CompanyId + }); + continue; + } + var wrappedHtml = WrapRenderedHtml(renderedBody); var (success, error) = await _emailService.SendEmailAsync( - email, name, form.Subject, form.Body, htmlBody); + recipient.RecipientEmail, + recipient.RecipientName, + renderedSubject, + plainTextBody, + wrappedHtml, + replyToEmail: replyToEmail, + replyToName: replyToEmail is null ? null : replyToName); if (success) sent++; - else + else failed++; + + await WriteLogAsync(new NotificationLog { - failed++; - _logger.LogWarning("Broadcast email failed for {Email}: {Error}", email, error); - } + Channel = NotificationChannel.Email, + NotificationType = NotificationType.AdminEmail, + Status = success ? NotificationStatus.Sent : NotificationStatus.Failed, + RecipientName = recipient.RecipientName, + Recipient = recipient.RecipientEmail, + Subject = renderedSubject, + Message = plainTextBody, + ErrorMessage = error, + SentAt = DateTime.UtcNow, + CompanyId = recipient.CompanyId + }); } - TempData["Success"] = $"Broadcast sent: {sent} delivered, {failed} failed. Total recipients: {recipients.Count}."; + TempData["Success"] = $"Admin email processed for {recipients.Count} selected compan{(recipients.Count == 1 ? "y" : "ies")}: {sent} sent, {failed} failed, {skipped} skipped."; return RedirectToAction(nameof(Index)); } - /// - /// Builds the list of (email, name, unsubscribe-token) tuples for the given - /// targeting criteria. Companies are excluded when MarketingEmailOptOut - /// is true — honouring prior unsubscribes — or when PrimaryContactEmail - /// is missing. The "specific" target requires at least one companyIds - /// entry and returns an empty list otherwise to prevent accidental all-company sends. - /// IgnoreQueryFilters() is required because this query spans companies. - /// - private async Task> BuildRecipientListAsync( - string? target, string? planFilter, int[]? companyIds) + private bool ValidateComposeModel(AdminEmailComposeModel form) { - var companyQuery = _db.Companies.AsNoTracking().IgnoreQueryFilters() - .Where(c => !c.IsDeleted && c.IsActive && !string.IsNullOrEmpty(c.PrimaryContactEmail) - && !c.MarketingEmailOptOut); + if (string.IsNullOrWhiteSpace(form.Subject)) + ModelState.AddModelError(nameof(form.Subject), "Subject is required."); - target ??= "active"; + if (string.IsNullOrWhiteSpace(ConvertHtmlToPlainText(form.BodyHtml))) + ModelState.AddModelError(nameof(form.BodyHtml), "Message body is required."); - switch (target) + return ModelState.IsValid; + } + + private bool ValidateCompanySelection(AdminEmailSelectionModel form) + { + if (form.CompanyIds == null || form.CompanyIds.Length == 0) + ModelState.AddModelError(nameof(form.CompanyIds), "Select at least one company."); + + return ModelState.IsValid; + } + + private static void NormalizeComposeModel(AdminEmailComposeModel form) + { + form.Subject = (form.Subject ?? string.Empty).Trim(); + form.BodyHtml = SanitizeHtml(form.BodyHtml); + } + + private async Task BuildSelectionModelAsync(AdminEmailComposeModel form) + { + var selectedIds = form is AdminEmailSelectionModel selection && selection.CompanyIds != null + ? selection.CompanyIds + : Array.Empty(); + + return new AdminEmailSelectionModel { - case "active": - companyQuery = companyQuery.Where(c => - c.SubscriptionStatus == SubscriptionStatus.Active || - c.SubscriptionStatus == SubscriptionStatus.GracePeriod); - break; - case "plan": - if (!string.IsNullOrWhiteSpace(planFilter) && int.TryParse(planFilter, out var planInt)) - companyQuery = companyQuery.Where(c => c.SubscriptionPlan == planInt); - break; - case "status_grace": - companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.GracePeriod); - break; - case "status_expired": - companyQuery = companyQuery.Where(c => c.SubscriptionStatus == SubscriptionStatus.Expired); - break; - case "specific": - if (companyIds != null && companyIds.Length > 0) - companyQuery = companyQuery.Where(c => companyIds.Contains(c.Id)); - else - return new List<(string, string, string)>(); - break; - case "all": - default: - break; + Subject = form.Subject, + BodyHtml = form.BodyHtml, + CompanyIds = selectedIds, + AvailableCompanies = await LoadCompanyOptionsAsync(selectedIds) + }; + } + + private async Task BuildPreviewModelAsync(AdminEmailSelectionModel form) + { + var recipients = await LoadRecipientContextsAsync(form.CompanyIds!); + if (recipients.Count == 0) + { + return new AdminEmailPreviewModel + { + Subject = form.Subject, + BodyHtml = form.BodyHtml, + CompanyIds = form.CompanyIds, + SelectedCompanies = [], + EligibleCount = 0, + SkippedCount = 0 + }; } - var companies = await companyQuery - .Select(c => new { c.PrimaryContactEmail, c.CompanyName, c.MarketingUnsubscribeToken }) - .ToListAsync(); + var sampleRecipient = recipients.FirstOrDefault(r => !string.IsNullOrWhiteSpace(r.RecipientEmail)) + ?? recipients.First(); - return companies - .Where(c => !string.IsNullOrWhiteSpace(c.PrimaryContactEmail)) - .Select(c => (c.PrimaryContactEmail!, c.CompanyName, c.MarketingUnsubscribeToken)) - .ToList(); + var sampleSubject = RenderPlainTemplate(form.Subject, sampleRecipient); + var sampleBody = RenderHtmlTemplate(form.BodyHtml, sampleRecipient); + + return new AdminEmailPreviewModel + { + Subject = form.Subject, + BodyHtml = form.BodyHtml, + CompanyIds = form.CompanyIds, + SelectedCompanies = recipients.Select(r => new AdminEmailRecipientPreviewRow + { + CompanyId = r.CompanyId, + CompanyName = r.CompanyName, + RecipientName = r.RecipientName, + RecipientEmail = r.RecipientEmail, + CompanyAdminName = r.CompanyAdminName, + CompanyAdminEmail = r.CompanyAdminEmail, + CanSend = !string.IsNullOrWhiteSpace(r.RecipientEmail), + SkipReason = string.IsNullOrWhiteSpace(r.RecipientEmail) + ? "Missing primary contact email" + : null + }).ToList(), + EligibleCount = recipients.Count(r => !string.IsNullOrWhiteSpace(r.RecipientEmail)), + SkippedCount = recipients.Count(r => string.IsNullOrWhiteSpace(r.RecipientEmail)), + SamplePreview = new AdminEmailRenderedPreview + { + CompanyName = sampleRecipient.CompanyName, + RecipientName = sampleRecipient.RecipientName, + RecipientEmail = sampleRecipient.RecipientEmail, + RenderedSubject = sampleSubject, + RenderedHtmlBody = WrapRenderedHtml(sampleBody) + } + }; } - /// - /// Hydrates ViewBag with the data sets needed by the broadcast compose view: - /// active subscription plan configs (for the plan-filter dropdown), - /// all non-deleted active companies (for the specific-company picker), - /// and a live count of active/grace-period companies shown in the UI summary. - /// Centralised here so it can be called from both and the - /// validation-failure branch of . - /// - private async Task PopulateViewBag() + private async Task> LoadCompanyOptionsAsync(IReadOnlyCollection selectedIds) { - ViewBag.PlanConfigs = await _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters() - .Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToListAsync(); - - ViewBag.Companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters() - .Where(c => !c.IsDeleted && c.IsActive) + var companies = await _db.Companies + .AsNoTracking() + .IgnoreQueryFilters() + .Where(c => !c.IsDeleted) .OrderBy(c => c.CompanyName) - .Select(c => new { c.Id, c.CompanyName }) + .Select(c => new AdminEmailCompanyOption + { + CompanyId = c.Id, + CompanyName = c.CompanyName, + PrimaryContactName = c.PrimaryContactName, + PrimaryContactEmail = c.PrimaryContactEmail, + IsActive = c.IsActive + }) .ToListAsync(); - ViewBag.ActiveCount = await _db.Companies.AsNoTracking().IgnoreQueryFilters() - .CountAsync(c => !c.IsDeleted && c.IsActive && - (c.SubscriptionStatus == SubscriptionStatus.Active || - c.SubscriptionStatus == SubscriptionStatus.GracePeriod)); + var adminLookup = await LoadCompanyAdminLookupAsync(companies.Select(c => c.CompanyId).ToArray()); + + foreach (var company in companies) + { + company.IsSelected = selectedIds.Contains(company.CompanyId); + if (adminLookup.TryGetValue(company.CompanyId, out var admin)) + { + company.CompanyAdminName = admin.FullName; + company.CompanyAdminEmail = admin.Email; + } + } + + return companies; + } + + private async Task> LoadRecipientContextsAsync(IReadOnlyCollection companyIds) + { + var companies = await _db.Companies + .AsNoTracking() + .IgnoreQueryFilters() + .Where(c => companyIds.Contains(c.Id) && !c.IsDeleted) + .OrderBy(c => c.CompanyName) + .Select(c => new + { + c.Id, + c.CompanyName, + c.PrimaryContactName, + c.PrimaryContactEmail + }) + .ToListAsync(); + + var adminLookup = await LoadCompanyAdminLookupAsync(companies.Select(c => c.Id).ToArray()); + + return companies.Select(company => + { + adminLookup.TryGetValue(company.Id, out var admin); + + return new AdminEmailRecipientContext + { + CompanyId = company.Id, + CompanyName = company.CompanyName, + RecipientName = string.IsNullOrWhiteSpace(company.PrimaryContactName) + ? company.CompanyName + : company.PrimaryContactName, + RecipientEmail = company.PrimaryContactEmail, + FirstName = ExtractFirstName(company.PrimaryContactName, company.CompanyName), + PrimaryContactName = company.PrimaryContactName, + CompanyAdminName = admin?.FullName, + CompanyAdminEmail = admin?.Email, + CompanyAdminFirstName = ExtractFirstName(admin?.FullName, company.CompanyName) + }; + }).ToList(); + } + + private async Task> LoadCompanyAdminLookupAsync(IReadOnlyCollection companyIds) + { + var admins = await _db.Users + .AsNoTracking() + .Where(u => companyIds.Contains(u.CompanyId) + && u.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin + && u.IsActive) + .OrderBy(u => u.CreatedAt) + .Select(u => new + { + u.CompanyId, + u.FirstName, + u.LastName, + u.Email + }) + .ToListAsync(); + + return admins + .GroupBy(u => u.CompanyId) + .ToDictionary( + g => g.Key, + g => + { + var admin = g.First(); + return new CompanyAdminLookup + { + FullName = $"{admin.FirstName} {admin.LastName}".Trim(), + Email = admin.Email ?? string.Empty + }; + }); + } + + private async Task GetAdminReplyToAsync() + { + var raw = await _platformSettings.GetAsync(PlatformSettingKeys.AdminNotificationEmail); + if (string.IsNullOrWhiteSpace(raw)) + return null; + + return raw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault(email => email.Contains('@')); + } + + private async Task WriteLogAsync(NotificationLog log) + { + try + { + await _db.NotificationLogs.AddAsync(log); + await _db.SaveChangesAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to write admin email notification log for company {CompanyId}", log.CompanyId); + } + } + + private static string RenderPlainTemplate(string template, AdminEmailRecipientContext recipient) + { + var rendered = template ?? string.Empty; + foreach (var replacement in BuildReplacementDictionary(recipient)) + rendered = rendered.Replace(replacement.Key, replacement.Value, StringComparison.OrdinalIgnoreCase); + + return rendered.Trim(); + } + + private static string RenderHtmlTemplate(string templateHtml, AdminEmailRecipientContext recipient) + { + var rendered = templateHtml ?? string.Empty; + foreach (var replacement in BuildReplacementDictionary(recipient)) + rendered = rendered.Replace( + replacement.Key, + WebUtility.HtmlEncode(replacement.Value), + StringComparison.OrdinalIgnoreCase); + + return rendered; + } + + private static Dictionary BuildReplacementDictionary(AdminEmailRecipientContext recipient) => new(StringComparer.OrdinalIgnoreCase) + { + ["{{FirstName}}"] = recipient.FirstName, + ["{{FullName}}"] = recipient.RecipientName, + ["{{CompanyName}}"] = recipient.CompanyName, + ["{{PrimaryContactName}}"] = recipient.PrimaryContactName ?? recipient.RecipientName, + ["{{PrimaryContactEmail}}"] = recipient.RecipientEmail ?? string.Empty, + ["{{CompanyAdminFirstName}}"] = recipient.CompanyAdminFirstName ?? string.Empty, + ["{{CompanyAdminName}}"] = recipient.CompanyAdminName ?? string.Empty, + ["{{CompanyAdminEmail}}"] = recipient.CompanyAdminEmail ?? string.Empty + }; + + private static string WrapRenderedHtml(string renderedHtmlBody) + { + return $""" +
+ {renderedHtmlBody} +
+ """; + } + + private static string SanitizeHtml(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + return string.Empty; + + var sanitized = CommentRegex.Replace(html, string.Empty); + sanitized = ScriptRegex.Replace(sanitized, string.Empty); + sanitized = sanitized.Replace("", "

", StringComparison.OrdinalIgnoreCase); + + sanitized = TagRegex.Replace(sanitized, match => + { + var isClosingTag = match.Groups[1].Value == "/"; + var tagName = match.Groups[2].Value.ToLowerInvariant(); + var attributes = match.Groups[3].Value; + + if (!AllowedTags.Contains(tagName)) + return string.Empty; + + if (tagName == "br") + return "
"; + + if (tagName == "a") + { + if (isClosingTag) + return ""; + + var href = HrefRegex.Match(attributes); + var hrefValue = href.Success ? href.Groups[2].Value : string.Empty; + if (!IsSafeHref(hrefValue)) + return string.Empty; + + var encodedHref = WebUtility.HtmlEncode(hrefValue); + return $""""""; + } + + return isClosingTag ? $"" : $"<{tagName}>"; + }); + + return sanitized.Trim(); + } + + private static bool IsSafeHref(string? href) + { + if (string.IsNullOrWhiteSpace(href)) + return false; + + return href.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + || href.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || href.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase); + } + + private static string ConvertHtmlToPlainText(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + return string.Empty; + + var plain = html; + plain = Regex.Replace(plain, @"<\s*br\s*/?\s*>", "\n", RegexOptions.IgnoreCase); + plain = Regex.Replace(plain, @"<\s*/\s*(p|h1|h2|h3|h4|blockquote)\s*>", "\n\n", RegexOptions.IgnoreCase); + plain = Regex.Replace(plain, @"<\s*li\s*>", "- ", RegexOptions.IgnoreCase); + plain = Regex.Replace(plain, @"<\s*/\s*li\s*>", "\n", RegexOptions.IgnoreCase); + plain = Regex.Replace(plain, @"<[^>]+>", string.Empty); + plain = WebUtility.HtmlDecode(plain); + plain = Regex.Replace(plain, @"\n{3,}", "\n\n"); + return plain.Trim(); + } + + private static string ExtractFirstName(string? fullName, string fallback) + { + if (string.IsNullOrWhiteSpace(fullName)) + return fallback; + + var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Length == 0 ? fallback : parts[0]; } } -public class BroadcastForm +public class AdminEmailComposeModel { - public string Target { get; set; } = "active"; - public string? PlanFilter { get; set; } - public int[]? CompanyIds { get; set; } - - [System.ComponentModel.DataAnnotations.Required] + [Required] public string Subject { get; set; } = string.Empty; - [System.ComponentModel.DataAnnotations.Required] - public string Body { get; set; } = string.Empty; + [Required] + public string BodyHtml { get; set; } = string.Empty; +} + +public class AdminEmailSelectionModel : AdminEmailComposeModel +{ + public int[]? CompanyIds { get; set; } + public List AvailableCompanies { get; set; } = []; +} + +public class AdminEmailSendRequest : AdminEmailSelectionModel; + +public class AdminEmailPreviewModel : AdminEmailSendRequest +{ + public List SelectedCompanies { get; set; } = []; + public int EligibleCount { get; set; } + public int SkippedCount { get; set; } + public AdminEmailRenderedPreview SamplePreview { get; set; } = new(); +} + +public class AdminEmailCompanyOption +{ + public int CompanyId { get; set; } + public string CompanyName { get; set; } = string.Empty; + public string? PrimaryContactName { get; set; } + public string? PrimaryContactEmail { get; set; } + public string? CompanyAdminName { get; set; } + public string? CompanyAdminEmail { get; set; } + public bool IsActive { get; set; } + public bool IsSelected { get; set; } +} + +public class AdminEmailRecipientPreviewRow +{ + public int CompanyId { get; set; } + public string CompanyName { get; set; } = string.Empty; + public string RecipientName { get; set; } = string.Empty; + public string? RecipientEmail { get; set; } + public string? CompanyAdminName { get; set; } + public string? CompanyAdminEmail { get; set; } + public bool CanSend { get; set; } + public string? SkipReason { get; set; } +} + +public class AdminEmailRenderedPreview +{ + public string CompanyName { get; set; } = string.Empty; + public string RecipientName { get; set; } = string.Empty; + public string? RecipientEmail { get; set; } + public string RenderedSubject { get; set; } = string.Empty; + public string RenderedHtmlBody { get; set; } = string.Empty; +} + +internal sealed class AdminEmailRecipientContext +{ + public int CompanyId { get; init; } + public string CompanyName { get; init; } = string.Empty; + public string RecipientName { get; init; } = string.Empty; + public string? RecipientEmail { get; init; } + public string FirstName { get; init; } = string.Empty; + public string? PrimaryContactName { get; init; } + public string? CompanyAdminFirstName { get; init; } + public string? CompanyAdminName { get; init; } + public string? CompanyAdminEmail { get; init; } +} + +internal sealed class CompanyAdminLookup +{ + public string FullName { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; } 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/GuidedActivationController.cs b/src/PowderCoating.Web/Controllers/GuidedActivationController.cs new file mode 100644 index 0000000..655a7f5 --- /dev/null +++ b/src/PowderCoating.Web/Controllers/GuidedActivationController.cs @@ -0,0 +1,229 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Interfaces; +using PowderCoating.Shared.Constants; +using PowderCoating.Web.ViewModels.GuidedActivation; + +namespace PowderCoating.Web.Controllers; + +[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] +public class GuidedActivationController : Controller +{ + private const string SampleCustomerName = "Sample Customer"; + private const string SampleCustomerMarker = "Guided activation sample customer"; + + private readonly IUnitOfWork _unitOfWork; + private readonly ITenantContext _tenantContext; + private readonly ILogger _logger; + + public GuidedActivationController( + IUnitOfWork unitOfWork, + ITenantContext tenantContext, + ILogger logger) + { + _unitOfWork = unitOfWork; + _tenantContext = tenantContext; + _logger = logger; + } + + [HttpGet] + public async Task Start() + { + var prefs = await LoadPreferencesAsync(); + if (!prefs.SetupWizardCompleted) + return RedirectToAction("Step", "SetupWizard", new { step = 1 }); + + if (prefs.FirstWorkflowCompleted) + return RedirectToAction("Index", "Dashboard"); + + if (string.IsNullOrWhiteSpace(prefs.OnboardingPath)) + return RedirectToAction(nameof(Select)); + + return prefs.OnboardingPath switch + { + AppConstants.GuidedActivation.QuoteFirstPath => RedirectToAction(nameof(StartQuoteFlow)), + AppConstants.GuidedActivation.JobFirstPath => RedirectToAction(nameof(StartJobFlow)), + _ => RedirectToAction(nameof(Select)) + }; + } + + [HttpGet] + public async Task Select() + { + var prefs = await LoadPreferencesAsync(); + if (!prefs.SetupWizardCompleted) + return RedirectToAction("Step", "SetupWizard", new { step = 1 }); + + if (prefs.FirstWorkflowCompleted) + return RedirectToAction("Index", "Dashboard"); + + return View(new GuidedActivationSelectionViewModel + { + OnboardingPath = prefs.OnboardingPath + }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Select(GuidedActivationSelectionViewModel model) + { + if (!ModelState.IsValid) + return View(model); + + if (model.OnboardingPath != AppConstants.GuidedActivation.QuoteFirstPath + && model.OnboardingPath != AppConstants.GuidedActivation.JobFirstPath) + { + ModelState.AddModelError(nameof(model.OnboardingPath), "Please choose a workflow path."); + return View(model); + } + + var prefs = await LoadPreferencesAsync(); + prefs.OnboardingPath = model.OnboardingPath; + prefs.GuidedActivationDismissedAt = null; + await _unitOfWork.CompleteAsync(); + + _logger.LogInformation("Guided activation path selected for company {CompanyId}: {Path}", + prefs.CompanyId, prefs.OnboardingPath); + + return RedirectToAction(nameof(Start)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Skip() + { + var prefs = await LoadPreferencesAsync(); + prefs.GuidedActivationDismissedAt = DateTime.UtcNow; + await _unitOfWork.CompleteAsync(); + + _logger.LogInformation("Guided activation dismissed for company {CompanyId}", prefs.CompanyId); + return RedirectToAction("Index", "Dashboard"); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task CompleteFromJob(int id) + { + var prefs = await LoadPreferencesAsync(); + if (!prefs.FirstWorkflowCompleted) + { + prefs.FirstWorkflowCompleted = true; + prefs.FirstWorkflowCompletedAt = DateTime.UtcNow; + prefs.GuidedActivationDismissedAt = null; + await _unitOfWork.CompleteAsync(); + + _logger.LogInformation("Guided activation completed from job {JobId} for company {CompanyId}", + id, prefs.CompanyId); + } + + TempData["Success"] = "Your first workflow is complete. You're ready to keep going."; + return RedirectToAction("Details", "Jobs", new { id, guidedActivation = AppConstants.GuidedActivation.JobCreatedStep }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task CompleteFromInvoice(int id) + { + var prefs = await LoadPreferencesAsync(); + if (!prefs.FirstWorkflowCompleted) + { + prefs.FirstWorkflowCompleted = true; + prefs.FirstWorkflowCompletedAt = DateTime.UtcNow; + prefs.GuidedActivationDismissedAt = null; + await _unitOfWork.CompleteAsync(); + + _logger.LogInformation("Guided activation completed from invoice {InvoiceId} for company {CompanyId}", + id, prefs.CompanyId); + } + + TempData["Success"] = "Your first workflow is complete. You're ready to keep going."; + return RedirectToAction("Details", "Invoices", new { id, guidedActivation = AppConstants.GuidedActivation.InvoiceCreatedStep }); + } + + [HttpGet] + public async Task StartQuoteFlow() + { + var prefs = await LoadPreferencesAsync(); + prefs.OnboardingPath = AppConstants.GuidedActivation.QuoteFirstPath; + prefs.GuidedActivationDismissedAt = null; + await _unitOfWork.CompleteAsync(); + + var customer = await GetOrCreateOnboardingCustomerAsync(prefs.CompanyId); + + return RedirectToAction("Create", "Quotes", new + { + customerId = customer.Id, + guidedActivation = AppConstants.GuidedActivation.QuoteFirstPath + }); + } + + [HttpGet] + public async Task StartJobFlow() + { + var prefs = await LoadPreferencesAsync(); + prefs.OnboardingPath = AppConstants.GuidedActivation.JobFirstPath; + prefs.GuidedActivationDismissedAt = null; + await _unitOfWork.CompleteAsync(); + + var customer = await GetOrCreateOnboardingCustomerAsync(prefs.CompanyId); + + return RedirectToAction("Create", "Jobs", new + { + customerId = customer.Id, + guidedActivation = AppConstants.GuidedActivation.JobFirstPath + }); + } + + private async Task LoadPreferencesAsync() + { + var companyId = _tenantContext.GetCurrentCompanyId(); + if (companyId == null) + throw new InvalidOperationException("No company context available for guided activation."); + + var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Preferences!) + ?? throw new InvalidOperationException("Company not found."); + + if (company.Preferences != null) + return company.Preferences; + + var prefs = new CompanyPreferences { CompanyId = companyId.Value }; + await _unitOfWork.CompanyPreferences.AddAsync(prefs); + await _unitOfWork.CompleteAsync(); + return prefs; + } + + private async Task GetOrCreateOnboardingCustomerAsync(int companyId) + { + var existing = (await _unitOfWork.Customers.FindAsync(c => + c.CompanyId == companyId + && !c.IsDeleted + && (c.GeneralNotes == SampleCustomerMarker || c.CompanyName == SampleCustomerName))) + .OrderBy(c => c.Id) + .FirstOrDefault(); + + if (existing != null) + return existing; + + var customer = new Customer + { + CompanyId = companyId, + CompanyName = SampleCustomerName, + ContactFirstName = "Sample", + ContactLastName = "Customer", + Phone = "(555) 010-0001", + IsCommercial = false, + GeneralNotes = SampleCustomerMarker, + CreatedAt = DateTime.UtcNow, + IsActive = true + }; + + await _unitOfWork.Customers.AddAsync(customer); + await _unitOfWork.CompleteAsync(); + + _logger.LogInformation("Created guided activation sample customer {CustomerId} for company {CompanyId}", + customer.Id, companyId); + + return customer; + } +} 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/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 2aa586a..120642e 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -2,7 +2,6 @@ using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Invoice; using PowderCoating.Application.Interfaces; @@ -10,7 +9,6 @@ using PowderCoating.Core.Entities; using Microsoft.AspNetCore.Mvc.Rendering; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; using PowderCoating.Web.Extensions; using PowderCoating.Web.Helpers; @@ -26,7 +24,6 @@ public class InvoicesController : Controller private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IPdfService _pdfService; - private readonly ApplicationDbContext _context; private readonly ITenantContext _tenantContext; private readonly INotificationService _notificationService; private readonly IAccountBalanceService _accountBalanceService; @@ -37,7 +34,6 @@ public class InvoicesController : Controller UserManager userManager, ILogger logger, IPdfService pdfService, - ApplicationDbContext context, ITenantContext tenantContext, INotificationService notificationService, IAccountBalanceService accountBalanceService) @@ -47,7 +43,6 @@ public class InvoicesController : Controller _userManager = userManager; _logger = logger; _pdfService = pdfService; - _context = context; _tenantContext = tenantContext; _notificationService = notificationService; _accountBalanceService = accountBalanceService; @@ -227,7 +222,7 @@ public class InvoicesController : Controller /// — Whether online payments are allowed: requires plan-level permission AND an active /// Stripe Connect account. Both conditions must be true; per-plan override wins if set. /// - public async Task Details(int? id) + public async Task Details(int? id, string? guidedActivation = null) { if (id == null) return NotFound(); @@ -256,13 +251,25 @@ public class InvoicesController : Controller // Check whether this company's plan allows online payments var company = await _unitOfWork.Companies.GetByIdAsync(_tenantContext.GetCurrentCompanyId() ?? 0); - var planConfig = company == null ? null : await _context.Set() - .AsNoTracking() + var planConfig = company == null ? null : await _unitOfWork.SubscriptionPlanConfigs .FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan); var onlinePaymentsAllowed = company?.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false); ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed && company?.StripeConnectStatus == StripeConnectStatus.Active; + if (guidedActivation == AppConstants.GuidedActivation.InvoiceCreatedStep) + { + ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel + { + Show = true, + Title = "This is how billing connects back to the job.", + Message = "You’ve already seen the shop workflow. From here you can send the invoice, collect payment, or head back to the dashboard.", + ActionText = "Go to Dashboard", + ActionController = "Dashboard", + ActionName = "Index" + }; + } + return View(dto); } catch (Exception ex) @@ -291,19 +298,17 @@ public class InvoicesController : Controller /// — Revenue accounts are pulled from the catalog item's RevenueAccountId, falling back to /// account 4000 (default revenue) if no catalog item is linked. /// - public async Task Create(int? jobId) + public async Task Create(int? jobId, string? guidedActivation = null) { try { var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var prefs = await _context.CompanyPreferences - .AsNoTracking() + var prefs = await _unitOfWork.CompanyPreferences .FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted); - var costs = await _context.CompanyOperatingCosts - .AsNoTracking() + var costs = await _unitOfWork.CompanyOperatingCosts .FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted); var dto = new CreateInvoiceDto @@ -321,9 +326,7 @@ public class InvoicesController : Controller if (job == null) return NotFound(); // Validate no existing invoice for this job - var existing = await _context.Set() - .IgnoreQueryFilters() - .FirstOrDefaultAsync(i => i.JobId == jobId.Value && i.CompanyId == currentUser.CompanyId && !i.IsDeleted); + var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value, includeDeleted: true); if (existing != null) return RedirectToAction(nameof(Details), new { id = existing.Id }); @@ -343,8 +346,8 @@ public class InvoicesController : Controller : new Dictionary(); // Fall back to the default revenue account (4000) if a catalog item has no specific account - var defaultRevenueAccount = await _context.Set() - .FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.CompanyId == currentUser.CompanyId && !a.IsDeleted); + var defaultRevenueAccount = await _unitOfWork.Accounts + .FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive); // If the job came from a quote, load it so we can use the agreed pricing. // The quote stores the approved total including oven batch cost and shop supplies — @@ -352,9 +355,7 @@ public class InvoicesController : Controller PowderCoating.Core.Entities.Quote? sourceQuote = null; if (job.QuoteId.HasValue) { - sourceQuote = await _context.Set() - .AsNoTracking() - .FirstOrDefaultAsync(q => q.Id == job.QuoteId.Value && !q.IsDeleted); + sourceQuote = await _unitOfWork.Quotes.GetByIdAsync(job.QuoteId.Value); } // Pre-populate from job items @@ -441,6 +442,7 @@ public class InvoicesController : Controller } await PopulateCreateViewBagAsync(currentUser.CompanyId); + ViewBag.GuidedActivation = guidedActivation; return View(dto); } catch (Exception ex) @@ -471,7 +473,7 @@ public class InvoicesController : Controller /// declared outside the lambda and assigned inside — EF requires this pattern with closures. /// [HttpPost, ValidateAntiForgeryToken] - public async Task Create(CreateInvoiceDto dto) + public async Task Create(CreateInvoiceDto dto, string? guidedActivation = null) { try { @@ -481,6 +483,7 @@ public class InvoicesController : Controller if (!ModelState.IsValid) { await PopulateCreateViewBagAsync(currentUser.CompanyId); + ViewBag.GuidedActivation = guidedActivation; return View(dto); } @@ -488,19 +491,19 @@ public class InvoicesController : Controller { ModelState.AddModelError("", "Please add at least one line item before saving."); await PopulateCreateViewBagAsync(currentUser.CompanyId); + ViewBag.GuidedActivation = guidedActivation; return View(dto); } // Validate no existing invoice for this job before starting the transaction if (dto.JobId.HasValue) { - var existing = await _context.Set() - .IgnoreQueryFilters() - .FirstOrDefaultAsync(i => i.JobId == dto.JobId && i.CompanyId == currentUser.CompanyId && !i.IsDeleted); + var existing = await _unitOfWork.Invoices.GetForJobAsync(dto.JobId.Value, includeDeleted: true); if (existing != null) { ModelState.AddModelError("", "An invoice already exists for this job."); await PopulateCreateViewBagAsync(currentUser.CompanyId); + ViewBag.GuidedActivation = guidedActivation; return View(dto); } } @@ -591,11 +594,10 @@ public class InvoicesController : Controller // Auto-apply any unapplied deposits for this job (and its linked quote) var job = dto.JobId.HasValue ? await _unitOfWork.Jobs.GetByIdAsync(dto.JobId.Value) : null; - var depositQuery = _context.Set() - .Where(d => !d.IsDeleted && d.CompanyId == currentUser.CompanyId - && d.AppliedToInvoiceId == null - && (d.JobId == dto.JobId || (job != null && job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value))); - pendingDeposits = await depositQuery.ToListAsync(); + pendingDeposits = (await _unitOfWork.Deposits.FindAsync( + d => d.AppliedToInvoiceId == null + && (d.JobId == dto.JobId || (job != null && job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value)))) + .ToList(); foreach (var deposit in pendingDeposits) { @@ -658,8 +660,21 @@ public class InvoicesController : Controller var depositMsg = pendingDeposits.Any() ? $" {pendingDeposits.Count} deposit(s) totaling {pendingDeposits.Sum(d => d.Amount):C} auto-applied." : ""; + var workflowJustCompleted = await StampInvoiceCreatedAsync(currentUser.CompanyId); TempData["Success"] = $"Invoice {invoiceNumber} created successfully.{depositMsg}{gcMsg}"; - return RedirectToAction(nameof(Details), new { id = invoice.Id }); + if (!string.IsNullOrWhiteSpace(guidedActivation) || workflowJustCompleted) + { + return RedirectToAction(nameof(Details), new + { + id = invoice!.Id, + guidedActivation = AppConstants.GuidedActivation.InvoiceCreatedStep + }); + } + + return RedirectToAction(nameof(Details), new + { + id = invoice!.Id + }); } catch (Exception ex) { @@ -667,6 +682,7 @@ public class InvoicesController : Controller TempData["Error"] = "An error occurred while creating the invoice."; var currentUser = await _userManager.GetUserAsync(User); if (currentUser != null) await PopulateCreateViewBagAsync(currentUser.CompanyId); + ViewBag.GuidedActivation = guidedActivation; return View(dto); } } @@ -893,24 +909,27 @@ public class InvoicesController : Controller if (!string.IsNullOrEmpty(invoice.PaymentLinkToken)) paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"; + bool pdfAndNotifSucceeded = false; try { var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId); await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl); + pdfAndNotifSucceeded = true; } catch (Exception notifyEx) { - _logger.LogWarning(notifyEx, "Invoice sent but notification failed for invoice {Id}", id); + _logger.LogError(notifyEx, + "Invoice {InvoiceId} ({InvoiceNumber}): PDF generation or email dispatch failed. " + + "Inner: {InnerMessage}. Invoice status was already saved as Sent.", + id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none"); } - var notifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.InvoiceId == id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); this.SetNotificationResultToast(notifLog); TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent."; + if (!pdfAndNotifSucceeded) + TempData["WarningPermanent"] = "The invoice is marked as sent, but PDF generation or the customer email failed. Check the notification logs or your email configuration."; return RedirectToAction(nameof(Details), new { id }); } catch (Exception ex) @@ -1036,11 +1055,7 @@ public class InvoicesController : Controller } - var paymentNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.InvoiceId == id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); this.SetNotificationResultToast(paymentNotifLog); TempData["Success"] = overpayment > 0 @@ -1295,8 +1310,8 @@ public class InvoicesController : Controller } catch (Exception ex) { - _logger.LogError(ex, "Error generating PDF for invoice {Id}", id); - TempData["Error"] = "An error occurred while generating the PDF."; + _logger.LogError(ex, "Error generating PDF for invoice {Id}. Inner: {Inner}", id, ex.InnerException?.Message ?? ex.Message); + TempData["ErrorPermanent"] = $"PDF generation failed: {ex.Message}"; return RedirectToAction(nameof(Details), new { id }); } } @@ -1318,9 +1333,7 @@ public class InvoicesController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var existing = await _context.Set() - .IgnoreQueryFilters() - .FirstOrDefaultAsync(i => i.JobId == jobId && i.CompanyId == currentUser.CompanyId && !i.IsDeleted); + var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId, includeDeleted: true); if (existing != null) return RedirectToAction(nameof(Details), new { id = existing.Id }); @@ -1379,11 +1392,7 @@ public class InvoicesController : Controller await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename); - var latestLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.InvoiceId == id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); if (latestLog?.Status == NotificationStatus.Failed) return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" }); @@ -1413,16 +1422,10 @@ public class InvoicesController : Controller public async Task NotificationsSent(int id) { var tz = ViewBag.CompanyTimeZone as string; - var raw = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.InvoiceId == id) - .OrderByDescending(n => n.SentAt) - .Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), - Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message, n.SentAt }) - .ToListAsync(); - - var logs = raw.Select(n => new { n.Id, n.Channel, n.Type, n.Status, n.RecipientName, - n.Recipient, n.Subject, n.ErrorMessage, n.Message, SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); + var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id); + var logs = entries.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), + Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message, + SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); return Json(logs); } @@ -1467,32 +1470,24 @@ public class InvoicesController : Controller } // Soft-delete line items - var invoiceItems = await _context.InvoiceItems - .Where(ii => ii.InvoiceId == id && !ii.IsDeleted) - .ToListAsync(); + var invoiceItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == id); foreach (var item in invoiceItems) await _unitOfWork.InvoiceItems.SoftDeleteAsync(item.Id); // Soft-delete any payments (draft invoices shouldn't have them, but be safe) - var payments = await _context.Payments - .Where(p => p.InvoiceId == id && !p.IsDeleted) - .ToListAsync(); + var payments = await _unitOfWork.Payments.FindAsync(p => p.InvoiceId == id); foreach (var payment in payments) await _unitOfWork.Payments.SoftDeleteAsync(payment.Id); // Un-apply any deposits that were applied to this invoice so they can be // re-applied if the invoice is recreated from the same job - var appliedDeposits = await _context.Deposits - .Where(d => d.AppliedToInvoiceId == id && !d.IsDeleted) - .ToListAsync(); + var appliedDeposits = await _unitOfWork.Deposits.FindAsync(d => d.AppliedToInvoiceId == id); foreach (var deposit in appliedDeposits) { deposit.AppliedToInvoiceId = null; deposit.AppliedDate = null; deposit.UpdatedAt = DateTime.UtcNow; } - if (appliedDeposits.Any()) - await _context.SaveChangesAsync(); // Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax var arAccountId = await GetArAccountIdAsync(invoice.CompanyId); @@ -1522,36 +1517,11 @@ public class InvoicesController : Controller // ----------------------------------------------------------------------- /// - /// Loads a complete invoice aggregate from the DB context (bypasses the generic repository - /// to allow the deep multi-level Include chain). Includes customer, job, preparer, all - /// non-deleted line items with revenue accounts and generated GCs, all non-deleted payments - /// with recorders and deposit accounts, refunds, credit applications, and GC redemptions. - /// Returns null if the invoice doesn't exist or is soft-deleted — callers check for null - /// and return NotFound rather than loading a partial object. + /// Delegates to which expresses the full + /// eight-table include chain. Returns null if not found or soft-deleted. /// - private async Task LoadInvoiceForViewAsync(int id) - { - return await _context.Set() - .Where(i => i.Id == id && !i.IsDeleted) - .Include(i => i.Customer) - .Include(i => i.Job) - .Include(i => i.PreparedBy) - .Include(i => i.SalesTaxAccount) - .Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted)) - .ThenInclude(ii => ii.RevenueAccount) - .Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted)) - .ThenInclude(ii => ii.GeneratedGiftCertificate) - .Include(i => i.Payments.Where(p => !p.IsDeleted)) - .ThenInclude(p => p.RecordedBy) - .Include(i => i.Payments.Where(p => !p.IsDeleted)) - .ThenInclude(p => p.DepositAccount) - .Include(i => i.Refunds.Where(r => !r.IsDeleted)) - .ThenInclude(r => r.IssuedBy) - .Include(i => i.CreditApplications.Where(ca => !ca.IsDeleted)) - .ThenInclude(ca => ca.CreditMemo) - .Include(i => i.GiftCertificateRedemptions) - .FirstOrDefaultAsync(); - } + private async Task LoadInvoiceForViewAsync(int id) => + await _unitOfWork.Invoices.LoadForViewAsync(id); /// /// Converts an Invoice entity to a fully populated InvoiceDto for the view layer. AutoMapper @@ -1730,11 +1700,10 @@ public class InvoicesController : Controller { var prefix = $"CM-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; - var existing = await _context.Set() - .IgnoreQueryFilters() - .Where(m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix)) + var existing = (await _unitOfWork.CreditMemos.FindAsync( + m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix), true)) .Select(m => m.MemoNumber) - .ToListAsync(); + .ToList(); var maxNum = 0; foreach (var num in existing) @@ -1757,26 +1726,18 @@ public class InvoicesController : Controller /// private async Task GenerateInvoiceNumberAsync(int companyId) { - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .Where(p => p.CompanyId == companyId && !p.IsDeleted) - .Select(p => new { p.InvoiceNumberPrefix }) - .FirstOrDefaultAsync(); + var prefs = await _unitOfWork.CompanyPreferences + .FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); var invoicePrefix = !string.IsNullOrWhiteSpace(prefs?.InvoiceNumberPrefix) ? prefs.InvoiceNumberPrefix : "INV"; var prefix = $"{invoicePrefix}-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; - var existing = await _context.Set() - .IgnoreQueryFilters() - .Where(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix)) - .Select(i => i.InvoiceNumber) - .ToListAsync(); - + var last = await _unitOfWork.Invoices.GetLastInvoiceNumberByPrefixAsync(companyId, prefix); var maxNum = 0; - foreach (var num in existing) + if (last != null && last.Length >= prefix.Length + 4) { - var suffix = num.Length >= prefix.Length + 4 ? num.Substring(prefix.Length) : ""; - if (int.TryParse(suffix, out int n) && n > maxNum) + var suffix = last.Substring(prefix.Length); + if (int.TryParse(suffix, out int n)) maxNum = n; } @@ -1795,8 +1756,7 @@ public class InvoicesController : Controller ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList(); // Expose company default tax rate and exempt customer IDs for client-side tax handling - var costs = await _context.CompanyOperatingCosts - .AsNoTracking() + var costs = await _unitOfWork.CompanyOperatingCosts .FirstOrDefaultAsync(c => c.CompanyId == companyId && !c.IsDeleted); ViewBag.CompanyTaxPercent = costs?.TaxPercent ?? 0; ViewBag.TaxExemptCustomerIds = customers @@ -1805,12 +1765,12 @@ public class InvoicesController : Controller .ToHashSet(); // Merchandise items for the invoice merch picker (all active IsMerchandise items) - var merchItems = await _context.Set() - .Include(i => i.Category) - .Where(i => i.IsMerchandise && i.IsActive && !i.IsDeleted && i.CompanyId == companyId) + var allMerchItems = await _unitOfWork.CatalogItems.FindAsync( + i => i.IsMerchandise && i.IsActive, false, i => i.Category); + var merchItems = allMerchItems .OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name) .Select(i => new { i.Id, i.Name, i.SKU, CategoryName = i.Category.Name, i.DefaultPrice, i.RevenueAccountId }) - .ToListAsync(); + .ToList(); ViewBag.MerchandiseItems = System.Text.Json.JsonSerializer.Serialize(merchItems, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); } @@ -1838,15 +1798,13 @@ public class InvoicesController : Controller return accounts.FirstOrDefault()?.Id; } - /// Looks up the "2200 Sales Tax Payable" account for this company, or any active OtherCurrentLiability with "tax" in the name. + /// Looks up the "2200 Sales Tax Payable" account for this company, or any active Liability account with "tax" in the name. private async Task ResolveSalesTaxAccountIdAsync(int companyId) { - var taxAccount = await _context.Set() - .Where(a => a.CompanyId == companyId && !a.IsDeleted && a.IsActive) - .OrderBy(a => a.AccountNumber == "2200" ? 0 : 1) - .ThenBy(a => a.AccountNumber) - .FirstOrDefaultAsync(a => a.AccountNumber == "2200" - || (a.AccountType == AccountType.Liability && a.Name.ToLower().Contains("tax"))); + var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.AccountNumber == "2200" && a.IsActive); + taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync( + a => a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax")); return taxAccount?.Id; } @@ -2364,16 +2322,13 @@ public class InvoicesController : Controller /// private async Task TryGeneratePaymentTokenAsync(Invoice invoice) { - var company = await _context.Companies - .AsNoTracking() - .FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted); + var company = await _unitOfWork.Companies.GetByIdAsync(invoice.CompanyId); if (company == null) return null; if (company.StripeConnectStatus != StripeConnectStatus.Active) return null; if (invoice.BalanceDue <= 0) return null; - var planConfig = await _context.Set() - .AsNoTracking() + var planConfig = await _unitOfWork.SubscriptionPlanConfigs .FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan); var onlinePaymentsAllowed = company.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false); @@ -2398,8 +2353,7 @@ public class InvoicesController : Controller { try { - var invoice = await _context.Invoices - .FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted); + var invoice = await _unitOfWork.Invoices.GetByIdAsync(id); if (invoice == null) return NotFound(); if (invoice.BalanceDue <= 0) @@ -2410,8 +2364,8 @@ public class InvoicesController : Controller return Json(new { success = false, message = "Online payments are not available for this company." }); invoice.UpdatedAt = DateTime.UtcNow; - _context.Update(invoice); - await _context.SaveChangesAsync(); + await _unitOfWork.Invoices.UpdateAsync(invoice); + await _unitOfWork.CompleteAsync(); var paymentUrl = Url.Action("Index", "Payment", new { token }, Request.Scheme)! .Replace("/Payment/Index/", "/pay/"); @@ -2445,27 +2399,11 @@ public class InvoicesController : Controller var endDate = to ?? startDate.AddMonths(1).AddDays(-1); var endDateInclusive = endDate.AddDays(1); - var invoices = await _context.Invoices - .AsNoTracking() - .Include(i => i.Customer) - .Where(i => !i.IsDeleted - && i.CompanyId == companyId - && i.OnlineAmountPaid > 0 - && i.UpdatedAt >= startDate - && i.UpdatedAt < endDateInclusive) - .OrderByDescending(i => i.UpdatedAt) - .ToListAsync(); + var invoices = await _unitOfWork.Invoices + .GetOnlineInvoicesForPeriodAsync(companyId, startDate, endDateInclusive); - var refunds = await _context.Refunds - .AsNoTracking() - .Include(r => r.Invoice).ThenInclude(inv => inv!.Customer) - .Where(r => !r.IsDeleted - && r.CompanyId == companyId - && r.RefundMethod == PaymentMethod.CreditDebitCard - && r.RefundDate >= startDate - && r.RefundDate < endDateInclusive) - .OrderByDescending(r => r.RefundDate) - .ToListAsync(); + var refunds = await _unitOfWork.Invoices + .GetOnlineRefundsForPeriodAsync(companyId, startDate, endDateInclusive); var vm = new OnlinePaymentsViewModel { @@ -2480,4 +2418,30 @@ public class InvoicesController : Controller return View(vm); } + + private async Task GetCompanyPreferencesAsync(int companyId) + { + return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); + } + + private async Task StampInvoiceCreatedAsync(int companyId) + { + var prefs = await GetCompanyPreferencesAsync(companyId); + if (prefs == null) + return false; + + var changed = false; + + if (!prefs.FirstInvoiceCreatedAt.HasValue) + { + prefs.FirstInvoiceCreatedAt = DateTime.UtcNow; + changed = true; + _logger.LogInformation("Recorded first invoice creation for company {CompanyId}", companyId); + } + + if (changed) + await _unitOfWork.CompleteAsync(); + + return false; + } } 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/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index 52c0fce..175de6d 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -15,7 +15,6 @@ using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Web.Helpers; using PowderCoating.Web.Hubs; @@ -29,7 +28,6 @@ public class JobsController : Controller private readonly IJobPhotoService _jobPhotoService; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly ApplicationDbContext _context; private readonly ITenantContext _tenantContext; private readonly IMeasurementConversionService _measurementService; private readonly ILookupCacheService _lookupCache; @@ -45,7 +43,6 @@ public class JobsController : Controller IJobPhotoService jobPhotoService, UserManager userManager, ILogger logger, - ApplicationDbContext context, ITenantContext tenantContext, IMeasurementConversionService measurementService, ILookupCacheService lookupCache, @@ -60,7 +57,6 @@ public class JobsController : Controller _jobPhotoService = jobPhotoService; _userManager = userManager; _logger = logger; - _context = context; _tenantContext = tenantContext; _measurementService = measurementService; _lookupCache = lookupCache; @@ -229,7 +225,10 @@ public class JobsController : Controller /// columns are also shown for historical context. /// Uses the lookup cache so column headers stay consistent with the configurable status list. /// - public async Task Board(bool showTerminal = false) + public async Task Board( + bool showTerminal = false, + string? guidedActivation = null, + int? highlightJobId = null) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; @@ -239,17 +238,10 @@ public class JobsController : Controller .ToList(); // Load all active jobs with related data - var jobs = await _context.Jobs - .AsNoTracking() - .Where(j => !j.IsDeleted) - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Include(j => j.JobPriority) - .Include(j => j.AssignedUser) - .OrderBy(j => j.DueDate.HasValue ? 0 : 1) - .ThenBy(j => j.DueDate) - .ThenBy(j => j.JobPriority.DisplayOrder) - .ToListAsync(); + var jobs = await _unitOfWork.Jobs.GetBoardJobsAsync(); + var highlightedJob = highlightJobId.HasValue + ? jobs.FirstOrDefault(j => j.Id == highlightJobId.Value) + : null; var now = DateTime.UtcNow.Date; @@ -285,6 +277,13 @@ public class JobsController : Controller ViewBag.ShowTerminal = showTerminal; ViewBag.TotalTerminal = statuses.Where(s => s.IsTerminalStatus) .Sum(s => jobs.Count(j => j.JobStatusId == s.Id)); + ViewBag.GuidedActivation = guidedActivation; + ViewBag.GuidedActivationHighlightJobId = highlightJobId; + ViewBag.GuidedActivationCallout = await BuildBoardGuidedActivationCalloutAsync( + companyId, + guidedActivation, + highlightJobId, + highlightedJob); return View(columns); } @@ -296,15 +295,13 @@ public class JobsController : Controller [HttpPost, ValidateAntiForgeryToken] public async Task MoveCard([FromBody] MoveCardRequest req) { - var job = await _context.Jobs - .Include(j => j.JobStatus) - .FirstOrDefaultAsync(j => j.Id == req.JobId && !j.IsDeleted); + var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(req.JobId); if (job == null) return Json(new { success = false, message = "Job not found." }); - var newStatus = await _context.JobStatusLookups - .FirstOrDefaultAsync(s => s.Id == req.NewStatusId && s.IsActive); + var newStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync( + s => s.Id == req.NewStatusId && s.IsActive); if (newStatus == null) return Json(new { success = false, message = "Status not found." }); @@ -314,7 +311,11 @@ public class JobsController : Controller job.UpdatedAt = DateTime.UtcNow; job.UpdatedBy = User.Identity?.Name; - await _context.SaveChangesAsync(); + var workflowJustCompleted = + req.JobId == req.HighlightJobId + && await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, req.GuidedActivation); + + await _unitOfWork.CompleteAsync(); await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "StatusChanged", $"Status → {newStatus.DisplayName}"); @@ -334,7 +335,10 @@ public class JobsController : Controller success = true, newStatusId = newStatus.Id, newStatusDisplay = newStatus.DisplayName, - newStatusColor = newStatus.ColorClass + newStatusColor = newStatus.ColorClass, + guidedActivationNext = workflowJustCompleted + ? AppConstants.GuidedActivation.BoardReadyForInvoiceStep + : null }); } @@ -345,7 +349,7 @@ public class JobsController : Controller /// correctly without a separate AJAX call. Measurement units (sq ft vs m²) are resolved from /// the tenant's metric preference and passed via ViewBag. /// - public async Task Details(int? id) + public async Task Details(int? id, string? guidedActivation = null) { if (id == null) { @@ -354,49 +358,12 @@ public class JobsController : Controller try { - var job = await _unitOfWork.Jobs.GetByIdAsync(id.Value, false, - j => j.Customer, - j => j.JobStatus, - j => j.JobPriority, - j => j.JobItems, - j => j.AssignedUser, - j => j.Quote, - j => j.OvenCost, - j => j.OriginalJob, - j => j.IntakeCheckedBy); + var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value); if (job == null) { return NotFound(); } - // Load JobItemCoats and PrepServices for each JobItem - foreach (var item in job.JobItems) - { - var itemWithDetails = await _context.JobItems - .Where(ji => ji.Id == item.Id) - .Include(ji => ji.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(ji => ji.Coats) - .ThenInclude(c => c.Vendor) - .Include(ji => ji.PrepServices) - .ThenInclude(ps => ps.PrepService) - .AsNoTracking() - .FirstOrDefaultAsync(); - - if (itemWithDetails?.Coats != null) - item.Coats = itemWithDetails.Coats; - if (itemWithDetails?.PrepServices != null) - item.PrepServices = itemWithDetails.PrepServices; - } - - // Load prep services for this job - var jobPrepServices = await _context.JobPrepServices - .Where(jps => jps.JobId == id.Value && !jps.IsDeleted) - .Include(jps => jps.PrepService) - .AsNoTracking() - .ToListAsync(); - job.JobPrepServices = jobPrepServices; - // Build photo tag suggestions from coat colors on this job var coatColorSuggestions = job.JobItems .SelectMany(ji => ji.Coats) @@ -411,13 +378,7 @@ public class JobsController : Controller var jobDto = _mapper.Map(job); // Load change history - var changeHistories = await _context.JobChangeHistories - .Where(h => h.JobId == id.Value && !h.IsDeleted) - .Include(h => h.ChangedBy) - .OrderByDescending(h => h.ChangedAt) - .AsNoTracking() - .ToListAsync(); - + var changeHistories = await _unitOfWork.Jobs.GetChangeHistoryAsync(id.Value); _logger.LogInformation("Loaded {Count} change history records for Job {JobId}", changeHistories.Count, id.Value); var changeHistoryDtos = _mapper.Map>(changeHistories); @@ -435,10 +396,7 @@ public class JobsController : Controller ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); // Check if an invoice exists for this job - var jobInvoice = await _context.Set() - .Where(i => i.JobId == id.Value && !i.IsDeleted) - .Select(i => new { i.Id, i.InvoiceNumber, i.Status }) - .FirstOrDefaultAsync(); + var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(id.Value); ViewBag.JobInvoiceId = jobInvoice?.Id; ViewBag.JobInvoiceNumber = jobInvoice?.InvoiceNumber; ViewBag.JobInvoiceStatus = jobInvoice?.Status; @@ -498,21 +456,16 @@ public class JobsController : Controller }).ToList(); // Load deposits for this job (and any linked quote) - var depositQuery = _context.Set() - .Where(d => !d.IsDeleted && d.CompanyId == job.CompanyId - && (d.JobId == id.Value || (job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value))) - .Include(d => d.RecordedBy) - .OrderByDescending(d => d.ReceivedDate) - .AsNoTracking(); - ViewBag.Deposits = await depositQuery.ToListAsync(); + var jobDeposits = (await _unitOfWork.Deposits.FindAsync( + d => d.JobId == id.Value || (job.QuoteId.HasValue && d.QuoteId == job.QuoteId.Value), + false, d => d.RecordedBy)) + .OrderByDescending(d => d.ReceivedDate).ToList(); + ViewBag.Deposits = jobDeposits; // Materials used on this job via QR scan or manual log - ViewBag.MaterialsUsed = await _context.Set() - .Where(t => !t.IsDeleted && t.JobId == id.Value) - .Include(t => t.InventoryItem) - .OrderByDescending(t => t.TransactionDate) - .AsNoTracking() - .ToListAsync(); + ViewBag.MaterialsUsed = (await _unitOfWork.InventoryTransactions.FindAsync( + t => t.JobId == id.Value, false, t => t.InventoryItem)) + .OrderByDescending(t => t.TransactionDate).ToList(); // Job photo subscription limits — used to disable the upload button in the view var photoCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; @@ -535,6 +488,28 @@ public class JobsController : Controller .OrderBy(c => c.Text) .ToList(); + var jobPrefs = await GetCompanyPreferencesAsync(job.CompanyId); + if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep + && jobPrefs?.FirstWorkflowCompleted == false) + { + ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel + { + Show = true, + Title = jobPrefs.OnboardingPath == AppConstants.GuidedActivation.QuoteFirstPath + ? "Now your approved quote is a job. This is where you track it through your shop." + : "This job is now live in your shop workflow.", + Message = "Next, open the Daily Board and move it to the next stage so you can see how work flows across the shop.", + ActionText = "Open Daily Board", + ActionController = "Jobs", + ActionName = "Board", + ActionRouteValues = new + { + guidedActivation = AppConstants.GuidedActivation.BoardIntroStep, + highlightJobId = job.Id + } + }; + } + return View(jobDto); } catch (Exception ex) @@ -650,46 +625,12 @@ public class JobsController : Controller try { - var job = await _unitOfWork.Jobs.GetByIdAsync(id.Value, false, - j => j.Customer, - j => j.JobStatus, - j => j.JobPriority, - j => j.JobItems, - j => j.AssignedUser, - j => j.Quote); + var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value); if (job == null) { return NotFound(); } - // Load JobItemCoats and PrepServices for each JobItem - foreach (var item in job.JobItems) - { - var itemWithDetails = await _context.JobItems - .Where(ji => ji.Id == item.Id) - .Include(ji => ji.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(ji => ji.Coats) - .ThenInclude(c => c.Vendor) - .Include(ji => ji.PrepServices) - .ThenInclude(ps => ps.PrepService) - .AsNoTracking() - .FirstOrDefaultAsync(); - - if (itemWithDetails?.Coats != null) - item.Coats = itemWithDetails.Coats; - if (itemWithDetails?.PrepServices != null) - item.PrepServices = itemWithDetails.PrepServices; - } - - // Load prep services for this job - var jobPrepServices = await _context.JobPrepServices - .Where(jps => jps.JobId == id.Value && !jps.IsDeleted) - .Include(jps => jps.PrepService) - .AsNoTracking() - .ToListAsync(); - job.JobPrepServices = jobPrepServices; - // Use AutoMapper to map the job entity to JobDto var jobDto = _mapper.Map(job); @@ -918,7 +859,7 @@ public class JobsController : Controller /// (pre-configured job types with standard items). If is provided, /// the customer dropdown is pre-selected. The wizard is the same multi-step UI as the quote wizard. /// - public async Task Create(int? customerId, int? templateId) + public async Task Create(int? customerId, int? templateId, string? guidedActivation = null) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; @@ -940,19 +881,31 @@ public class JobsController : Controller if (customerId.HasValue) dto.CustomerId = customerId.Value; + if (guidedActivation == AppConstants.GuidedActivation.JobFirstPath && customerId.HasValue) + { + dto = GuidedActivationDefaults.BuildJobDraft(customerId.Value, normalPriority?.Id ?? 1); + } + // Pre-populate from template if provided if (templateId.HasValue) { - var template = await _context.JobTemplates - .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) - .FirstOrDefaultAsync(t => t.Id == templateId.Value && !t.IsDeleted); - + var template = await _unitOfWork.JobTemplates.GetByIdAsync(templateId.Value); if (template != null) { + var templateItemsEnum = await _unitOfWork.JobTemplateItems.FindAsync( + i => i.JobTemplateId == templateId.Value, false, i => i.Coats, i => i.PrepServices); + var templateItems = templateItemsEnum.ToList(); + + var tplPrepIds = templateItems + .SelectMany(i => i.PrepServices.Select(p => p.PrepServiceId)) + .Distinct().ToList(); + Dictionary tplPrepNameMap = new(); + if (tplPrepIds.Any()) + { + var tplPreps = await _unitOfWork.PrepServices.FindAsync(p => tplPrepIds.Contains(p.Id)); + tplPrepNameMap = tplPreps.ToDictionary(p => p.Id, p => p.ServiceName); + } + if (!customerId.HasValue && template.CustomerId.HasValue) dto.CustomerId = template.CustomerId.Value; dto.SpecialInstructions = template.SpecialInstructions; @@ -962,7 +915,7 @@ public class JobsController : Controller { id = template.Id, name = template.Name, - items = template.Items.OrderBy(i => i.DisplayOrder).Select(i => new + items = templateItems.OrderBy(i => i.DisplayOrder).Select(i => new { description = i.Description, quantity = i.Quantity, @@ -993,7 +946,7 @@ public class JobsController : Controller prepServices = i.PrepServices.Select(p => new { prepServiceId = p.PrepServiceId, - prepServiceName = p.PrepService?.ServiceName, + prepServiceName = tplPrepNameMap.TryGetValue(p.PrepServiceId, out var psName) ? psName : null, estimatedMinutes = p.EstimatedMinutes }) }) @@ -1001,6 +954,7 @@ public class JobsController : Controller } } + ViewBag.GuidedActivation = guidedActivation; return View(dto); } @@ -1013,13 +967,14 @@ public class JobsController : Controller /// [HttpPost] [ValidateAntiForgeryToken] - public async Task Create(CreateJobDto dto) + public async Task Create(CreateJobDto dto, string? guidedActivation = null) { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; if (!ModelState.IsValid) { await PopulateCreateEditWizardViewBagsAsync(companyId); + ViewBag.GuidedActivation = guidedActivation; return View(dto); } @@ -1031,6 +986,7 @@ public class JobsController : Controller $"You have reached your plan limit of {max} active jobs. " + "Please upgrade your plan or complete/cancel existing jobs to add more."); await PopulateCreateEditWizardViewBagsAsync(companyId); + ViewBag.GuidedActivation = guidedActivation; return View(dto); } @@ -1071,14 +1027,14 @@ public class JobsController : Controller { foreach (var prepServiceId in dto.PrepServiceIds) { - await _context.JobPrepServices.AddAsync(new JobPrepService + await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService { JobId = job.Id, PrepServiceId = prepServiceId, CompanyId = companyId }); } - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); } // Save job items from wizard @@ -1154,7 +1110,7 @@ public class JobsController : Controller { foreach (var psDto in itemDto.PrepServices) { - await _context.JobItemPrepServices.AddAsync(new JobItemPrepService + await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService { JobItemId = jobItem.Id, PrepServiceId = psDto.PrepServiceId, @@ -1186,7 +1142,18 @@ public class JobsController : Controller if (!string.IsNullOrEmpty(createCompanyId)) await _shopHub.Clients.Group($"shop-{createCompanyId}").SendAsync("DailyBoardUpdated"); + await StampJobCreatedAsync(companyId); + this.ToastSuccess($"Job {job.JobNumber} created successfully!"); + if (guidedActivation == AppConstants.GuidedActivation.JobFirstPath) + { + return RedirectToAction(nameof(Details), new + { + id = job.Id, + guidedActivation = AppConstants.GuidedActivation.JobCreatedStep + }); + } + return RedirectToAction(nameof(Details), new { id = job.Id }); } catch (Exception ex) @@ -1194,6 +1161,7 @@ public class JobsController : Controller _logger.LogError(ex, "Error creating job"); this.ToastError("An error occurred while creating the job. Please try again."); await PopulateCreateEditWizardViewBagsAsync(companyId); + ViewBag.GuidedActivation = guidedActivation; return View(dto); } } @@ -1212,26 +1180,12 @@ public class JobsController : Controller try { - var job = await _unitOfWork.Jobs.GetByIdAsync(id.Value, false, j => j.JobStatus, j => j.JobPriority); + var job = await _unitOfWork.Jobs.LoadForEditAsync(id.Value); if (job == null) { return NotFound(); } - // Load prep services for this job - var jobPrepServices = await _context.JobPrepServices - .Where(jps => jps.JobId == id.Value && !jps.IsDeleted) - .Select(jps => jps.PrepServiceId) - .ToListAsync(); - - // Load job items with full detail for wizard pre-fill - var jobItemsWithDetail = await _context.JobItems - .Where(ji => ji.JobId == id.Value && !ji.IsDeleted) - .Include(ji => ji.Coats) - .Include(ji => ji.PrepServices) - .AsNoTracking() - .ToListAsync(); - var dto = new UpdateJobDto { Id = job.Id, @@ -1252,7 +1206,7 @@ public class JobsController : Controller DiscountType = job.DiscountType.ToString(), DiscountValue = job.DiscountValue, DiscountReason = job.DiscountReason, - JobItems = jobItemsWithDetail.Select(ji => new CreateQuoteItemDto + JobItems = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => new CreateQuoteItemDto { Description = ji.Description, Quantity = ji.Quantity, @@ -1289,7 +1243,7 @@ public class JobsController : Controller EstimatedMinutes = ps.EstimatedMinutes }).ToList() }).ToList(), - PrepServiceIds = jobPrepServices + PrepServiceIds = job.JobPrepServices.Select(jps => jps.PrepServiceId).ToList() }; var editCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; @@ -1339,9 +1293,8 @@ public class JobsController : Controller try { // Replace job items: soft-delete old, recreate from wizard - var oldItems = await _context.JobItems - .Where(ji => ji.JobId == id && !ji.IsDeleted) - .ToListAsync(); + var oldItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == id); + var oldItems = oldItemsEnum.ToList(); var itemsChanged = dto.JobItems.Any() || oldItems.Any(); foreach (var oldItem in oldItems) @@ -1349,7 +1302,7 @@ public class JobsController : Controller oldItem.IsDeleted = true; oldItem.DeletedAt = DateTime.UtcNow; } - if (oldItems.Any()) await _context.SaveChangesAsync(); + if (oldItems.Any()) await _unitOfWork.CompleteAsync(); foreach (var itemDto in dto.JobItems) { @@ -1419,7 +1372,7 @@ public class JobsController : Controller { foreach (var psDto in itemDto.PrepServices) { - await _context.JobItemPrepServices.AddAsync(new JobItemPrepService + await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService { JobItemId = jobItem.Id, PrepServiceId = psDto.PrepServiceId, @@ -1698,11 +1651,13 @@ public class JobsController : Controller } // Update prep services - // Delete existing prep services - var existingPrepServices = await _context.JobPrepServices - .Where(jps => jps.JobId == job.Id && !jps.IsDeleted) - .ToListAsync(); - _context.JobPrepServices.RemoveRange(existingPrepServices); + // Soft-delete existing prep services + var existingPrepServicesEnum = await _unitOfWork.JobPrepServices.FindAsync(jps => jps.JobId == job.Id); + foreach (var eps in existingPrepServicesEnum) + { + eps.IsDeleted = true; + eps.DeletedAt = DateTime.UtcNow; + } // Add new prep services if (dto.PrepServiceIds != null && dto.PrepServiceIds.Any()) @@ -1715,7 +1670,7 @@ public class JobsController : Controller PrepServiceId = prepServiceId, CompanyId = currentUser.CompanyId }; - await _context.JobPrepServices.AddAsync(jobPrepService); + await _unitOfWork.JobPrepServices.AddAsync(jobPrepService); } _logger.LogInformation("Updated prep services for job {JobNumber}: {Count} services", job.JobNumber, dto.PrepServiceIds.Count); } @@ -1753,11 +1708,7 @@ public class JobsController : Controller _logger.LogWarning(ex, "Notification failed for job {Id}", job.Id); } - var editNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.JobId == job.Id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var editNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(job.Id); this.SetNotificationResultToast(editNotifLog); } @@ -1945,23 +1896,13 @@ public class JobsController : Controller var month = DateTime.Now.Month.ToString("D2"); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .Where(p => p.CompanyId == companyId && !p.IsDeleted) - .Select(p => new { p.JobNumberPrefix }) - .FirstOrDefaultAsync(); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB"; var prefix = $"{jobPrefix}-{year}{month}"; - // IgnoreQueryFilters so soft-deleted jobs are counted (prevents number reuse) - // Explicit CompanyId filter scopes to current company only - var lastJobNumber = await _context.Jobs - .IgnoreQueryFilters() - .Where(j => j.CompanyId == companyId && j.JobNumber.StartsWith(prefix)) - .OrderByDescending(j => j.JobNumber) - .Select(j => j.JobNumber) - .FirstOrDefaultAsync(); + var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix); if (lastJobNumber != null) { @@ -1987,27 +1928,14 @@ public class JobsController : Controller var today = date?.Date ?? DateTime.Today; // Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel) - var allStatuses = await _context.JobStatusLookups - .Where(s => !s.IsDeleted && s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED" - && s.StatusCode != "DELIVERED" && s.StatusCode != "QUOTED" - && s.StatusCode != "PENDING" && s.StatusCode != "APPROVED") - .OrderBy(s => s.DisplayOrder) - .ToListAsync(); + var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => + s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED" + && s.StatusCode != "DELIVERED" && s.StatusCode != "QUOTED" + && s.StatusCode != "PENDING" && s.StatusCode != "APPROVED"); + var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); // Get all jobs scheduled for today with related data including items and coats - var jobQuery = _context.Jobs - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Include(j => j.JobPriority) - .Include(j => j.AssignedUser) - .Include(j => j.JobItems.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.Coats) - .Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today && !j.IsDeleted && !j.JobStatus.IsTerminalStatus); - - if (!string.IsNullOrEmpty(userId)) - jobQuery = jobQuery.Where(j => j.AssignedUserId == userId); - - var jobs = await jobQuery.ToListAsync(); + var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today, userId); // Get existing priority records for today var existingPriorities = await _unitOfWork.JobDailyPriorities @@ -2107,27 +2035,13 @@ public class JobsController : Controller var companyId = _tenantContext.GetCurrentCompanyId(); if (!companyId.HasValue) return RedirectToAction(nameof(Index)); - var allStatuses = await _context.JobStatusLookups - .Where(s => !s.IsDeleted && !s.IsTerminalStatus - && s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED" - && s.StatusCode != "DELIVERED") - .OrderBy(s => s.DisplayOrder) - .ToListAsync(); + var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => + !s.IsTerminalStatus + && s.StatusCode != "ON_HOLD" && s.StatusCode != "CANCELLED" + && s.StatusCode != "DELIVERED"); + var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); - var jobQuery = _context.Jobs - .Include(j => j.Customer) - .Include(j => j.JobStatus) - .Include(j => j.JobPriority) - .Include(j => j.AssignedUser) - .Include(j => j.JobItems.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.Coats) - .Where(j => j.CompanyId == companyId && !j.IsDeleted && !j.JobStatus.IsTerminalStatus - && j.JobStatus.StatusCode != "ON_HOLD" && j.JobStatus.StatusCode != "CANCELLED"); - - if (!string.IsNullOrEmpty(workerId)) - jobQuery = jobQuery.Where(j => j.AssignedUserId == workerId); - - var jobs = await jobQuery.ToListAsync(); + var jobs = await _unitOfWork.Jobs.GetActiveJobsForMobileAsync(companyId.Value, workerId); var jobDtos = jobs.Select(j => { @@ -2251,12 +2165,10 @@ public class JobsController : Controller { try { - var job = await _context.Jobs - .Include(j => j.JobStatus) - .FirstOrDefaultAsync(j => j.Id == request.JobId && !j.IsDeleted); + var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(request.JobId); if (job == null) return Json(new { success = false, message = "Job not found" }); - var newStatus = await _context.JobStatusLookups.FindAsync(request.NewStatusId); + var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(request.NewStatusId); if (newStatus == null) return Json(new { success = false, message = "Status not found" }); var oldStatusId = job.JobStatusId; @@ -2276,6 +2188,10 @@ public class JobsController : Controller CreatedAt = DateTime.UtcNow }); + var workflowJustCompleted = + request.JobId == request.HighlightJobId + && await MaybeMarkFirstWorkflowCompletedFromBoardAsync(job.CompanyId, request.GuidedActivation); + await _unitOfWork.CompleteAsync(); var companyId = _tenantContext.GetCurrentCompanyId()?.ToString(); @@ -2288,7 +2204,15 @@ public class JobsController : Controller statusColorClass = newStatus.ColorClass }); - return Json(new { success = true, newStatusDisplayName = newStatus.DisplayName, newStatusColorClass = newStatus.ColorClass }); + return Json(new + { + success = true, + newStatusDisplayName = newStatus.DisplayName, + newStatusColorClass = newStatus.ColorClass, + guidedActivationNext = workflowJustCompleted + ? AppConstants.GuidedActivation.BoardReadyForInvoiceStep + : null + }); } catch (Exception ex) { @@ -2330,9 +2254,8 @@ public class JobsController : Controller /// /// AJAX endpoint for inline worker reassignment on the Job Details page. - /// Uses EF ExecuteUpdateAsync (bulk update without loading the entity) for efficiency — - /// no need to load the full job just to update one FK. The response includes the worker's - /// display name so the UI can update the badge without a page reload. + /// Loads the job, sets , and saves. + /// The response includes the worker's display name so the UI can update the badge without a page reload. /// Passing WorkerId = null unassigns the current worker. /// [HttpPost] @@ -2341,16 +2264,15 @@ public class JobsController : Controller { try { - var rowsAffected = await _context.Jobs - .Where(j => j.Id == request.JobId) - .ExecuteUpdateAsync(s => s - .SetProperty(j => j.AssignedUserId, request.WorkerId) - .SetProperty(j => j.UpdatedAt, DateTime.UtcNow)); - - if (rowsAffected == 0) + var workerJob = await _unitOfWork.Jobs.GetByIdAsync(request.JobId); + if (workerJob == null) { return Json(new { success = false, message = "Job not found" }); } + workerJob.AssignedUserId = request.WorkerId; + workerJob.UpdatedAt = DateTime.UtcNow; + await _unitOfWork.Jobs.UpdateAsync(workerJob); + await _unitOfWork.CompleteAsync(); string? workerName = null; if (!string.IsNullOrEmpty(request.WorkerId)) @@ -2359,13 +2281,9 @@ public class JobsController : Controller workerName = user?.FullName; } - var assignedJob = await _unitOfWork.Jobs.GetByIdAsync(request.JobId); - if (assignedJob != null) - { - var assignDetail = workerName != null ? $"Assigned to {workerName}" : "Worker unassigned"; - await BroadcastJobUpdate(assignedJob.CompanyId, assignedJob.JobNumber!, assignedJob.Id, - "WorkerChanged", assignDetail); - } + var assignDetail = workerName != null ? $"Assigned to {workerName}" : "Worker unassigned"; + await BroadcastJobUpdate(workerJob.CompanyId, workerJob.JobNumber!, workerJob.Id, + "WorkerChanged", assignDetail); return Json(new { success = true, workerName = workerName }); } @@ -2476,11 +2394,7 @@ public class JobsController : Controller _logger.LogWarning(ex, "Notification failed for job {Id}", request.JobId); } - var statusNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.JobId == request.JobId) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var statusNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(request.JobId); this.SetNotificationResultToast(statusNotifLog); } @@ -2545,14 +2459,8 @@ public class JobsController : Controller { try { - // Use AsNoTracking to fetch fresh data from database without EF caching - var photos = await _context.JobPhotos - .AsNoTracking() - .Include(p => p.UploadedBy) - .Where(p => p.JobId == jobId && !p.IsDeleted) - .OrderBy(p => p.DisplayOrder) - .ThenBy(p => p.UploadedDate) - .ToListAsync(); + var photosEnum = await _unitOfWork.JobPhotos.FindAsync(p => p.JobId == jobId, false, p => p.UploadedBy); + var photos = photosEnum.OrderBy(p => p.DisplayOrder).ThenBy(p => p.UploadedDate).ToList(); var photoDtos = photos .Select(p => _mapper.Map(p)) @@ -2740,7 +2648,7 @@ public class JobsController : Controller job.CompletedDate = DateTime.UtcNow; // Find the "Completed" status - var completedStatus = await _context.JobStatusLookups + var completedStatus = await _unitOfWork.JobStatusLookups .FirstOrDefaultAsync(s => s.StatusCode == "COMPLETED" && s.CompanyId == job.CompanyId); if (completedStatus != null) @@ -2844,11 +2752,7 @@ public class JobsController : Controller _logger.LogWarning(ex, "Notification failed for job {Id}", dto.JobId); } - var completeNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.JobId == dto.JobId) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var completeNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(dto.JobId); this.SetNotificationResultToast(completeNotifLog); } @@ -2999,9 +2903,8 @@ public class JobsController : Controller try { // Soft-delete existing job items (and their children) - var oldItems = await _context.JobItems - .Where(ji => ji.JobId == job.Id && !ji.IsDeleted) - .ToListAsync(); + var oldItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id); + var oldItems = oldItemsEnum.ToList(); foreach (var oldItem in oldItems) { @@ -3009,7 +2912,7 @@ public class JobsController : Controller oldItem.DeletedAt = DateTime.UtcNow; oldItem.DeletedBy = currentUser.UserName; } - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); // Create new items foreach (var itemDto in model.JobItems) @@ -3094,7 +2997,7 @@ public class JobsController : Controller CompanyId = currentUser.CompanyId, CreatedAt = DateTime.UtcNow }; - await _context.JobItemPrepServices.AddAsync(ps); + await _unitOfWork.JobItemPrepServices.AddAsync(ps); } } } @@ -3136,8 +3039,7 @@ public class JobsController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var item = await _context.JobItems - .FirstOrDefaultAsync(ji => ji.Id == id && !ji.IsDeleted); + var item = await _unitOfWork.JobItems.GetByIdAsync(id); if (item == null) return NotFound(); var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId); @@ -3146,14 +3048,11 @@ public class JobsController : Controller item.IsDeleted = true; item.DeletedAt = DateTime.UtcNow; item.DeletedBy = currentUser.UserName; - await _context.SaveChangesAsync(); + await _unitOfWork.CompleteAsync(); // Recalculate job total from remaining items - var remainingItems = await _context.JobItems - .Where(ji => ji.JobId == job.Id && !ji.IsDeleted) - .Include(ji => ji.Coats) - .AsNoTracking() - .ToListAsync(); + var remainingItemsEnum = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id, false, ji => ji.Coats); + var remainingItems = remainingItemsEnum.ToList(); var remainingDtos = remainingItems.Select(ji => new CreateQuoteItemDto { @@ -3475,12 +3374,7 @@ public class JobsController : Controller [HttpPost] public async Task AddReworkRecord([FromBody] CreateReworkRecordDto dto) { - 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 == dto.JobId && !j.IsDeleted); + var job = await _unitOfWork.Jobs.LoadForDetailsAsync(dto.JobId); if (job == null) return NotFound(); var companyId = job.CompanyId; @@ -3572,7 +3466,7 @@ public class JobsController : Controller foreach (var prep in item.PrepServices) { - _context.Set().Add(new JobItemPrepService + await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService { JobItemId = newItem.Id, PrepServiceId = prep.PrepServiceId, @@ -3742,16 +3636,7 @@ public class JobsController : Controller var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Load job with items, coats, and time entries - var job = await _context.Jobs - .Where(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted) - .Include(j => j.OvenCost) - .Include(j => j.Invoice) - .Include(j => j.JobItems.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) - .Include(j => j.TimeEntries.Where(t => !t.IsDeleted)) - .ThenInclude(t => t.Worker) - .AsNoTracking() - .FirstOrDefaultAsync(); + var job = await _unitOfWork.Jobs.LoadForCostingAsync(jobId, companyId); if (job == null) return NotFound(); @@ -3889,6 +3774,100 @@ public class JobsController : Controller return Json(new { error = "Unable to compute costing breakdown." }); } } + + private async Task GetCompanyPreferencesAsync(int companyId) + { + return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); + } + + private async Task BuildBoardGuidedActivationCalloutAsync( + int companyId, + string? guidedActivation, + int? highlightJobId, + Job? highlightedJob) + { + if (!highlightJobId.HasValue || highlightedJob == null) + return null; + + var prefs = await GetCompanyPreferencesAsync(companyId); + if (prefs == null || !prefs.SetupWizardCompleted || string.IsNullOrWhiteSpace(prefs.OnboardingPath)) + return null; + + if (guidedActivation == AppConstants.GuidedActivation.BoardIntroStep && !prefs.FirstWorkflowCompleted) + { + return new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel + { + Show = true, + Title = "This is your shop in real time", + Message = "Every active job shows up here so you can see what's in production, what's waiting, and what's ready to go.", + InstructionText = "Move this job to the next stage to see how your workflow updates.", + SecondaryActionText = "View job", + SecondaryActionController = "Jobs", + SecondaryActionName = "Details", + SecondaryActionRouteValues = new { id = highlightJobId.Value } + }; + } + + if (guidedActivation == AppConstants.GuidedActivation.BoardReadyForInvoiceStep) + { + var jobInvoice = await _unitOfWork.Invoices.GetForJobAsync(highlightJobId.Value); + var hasInvoice = jobInvoice != null; + + return new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel + { + Show = true, + Title = "Nice — your workflow just updated. This is how you track work through your shop.", + Message = hasInvoice + ? "You've already tied billing to this job. Open the invoice or keep exploring the board." + : "When the work is done, you can create the invoice.", + ActionText = hasInvoice ? "View Invoice" : "Create Invoice", + ActionController = "Invoices", + ActionName = hasInvoice ? "Details" : "Create", + ActionRouteValues = hasInvoice + ? new { id = jobInvoice!.Id } + : new + { + jobId = highlightJobId.Value, + guidedActivation = AppConstants.GuidedActivation.BoardReadyForInvoiceStep + }, + SecondaryActionText = "View job", + SecondaryActionController = "Jobs", + SecondaryActionName = "Details", + SecondaryActionRouteValues = new { id = highlightJobId.Value } + }; + } + + return null; + } + + private async Task MaybeMarkFirstWorkflowCompletedFromBoardAsync(int companyId, string? guidedActivation) + { + if (guidedActivation != AppConstants.GuidedActivation.BoardIntroStep) + return false; + + var prefs = await GetCompanyPreferencesAsync(companyId); + if (prefs == null || prefs.FirstWorkflowCompleted || !prefs.SetupWizardCompleted) + return false; + + prefs.FirstWorkflowCompleted = true; + prefs.FirstWorkflowCompletedAt = DateTime.UtcNow; + prefs.GuidedActivationDismissedAt = null; + + _logger.LogInformation("Marked first workflow complete from Daily Board tracking for company {CompanyId}", companyId); + return true; + } + + private async Task StampJobCreatedAsync(int companyId) + { + var prefs = await GetCompanyPreferencesAsync(companyId); + if (prefs == null || prefs.FirstJobCreatedAt.HasValue) + return; + + prefs.FirstJobCreatedAt = DateTime.UtcNow; + await _unitOfWork.CompleteAsync(); + + _logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId); + } } public class DeleteTimeEntryRequest { public int Id { get; set; } } @@ -3945,12 +3924,16 @@ public class MoveCardRequest { public int JobId { get; set; } public int NewStatusId { get; set; } + public string? GuidedActivation { get; set; } + public int? HighlightJobId { get; set; } } public class AdvanceJobStatusRequest { public int JobId { get; set; } public int NewStatusId { get; set; } + public string? GuidedActivation { get; set; } + public int? HighlightJobId { get; set; } } public class UpdateDatesRequest 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 134cc5b..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 { @@ -248,6 +249,10 @@ public class PasskeyController : Controller // Sign in — passkey satisfies both factors; no further 2FA required await _signInManager.SignInAsync(user, isPersistent: false); + // Track login date so CompanyHealth and audit pages show accurate last-login times + user.LastLoginDate = DateTime.UtcNow; + await _userManager.UpdateAsync(user); + _logger.LogInformation("User {UserId} signed in via passkey", user.Id); return Ok(new { redirectUrl = Url.Action("Index", "Dashboard") }); 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/PurchaseOrdersController.cs b/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs index 7715121..9e74070 100644 --- a/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs +++ b/src/PowderCoating.Web/Controllers/PurchaseOrdersController.cs @@ -3,14 +3,13 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.PurchaseOrder; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; +using PowderCoating.Core.Interfaces.Repositories; using PowderCoating.Shared.Constants; using PowderCoating.Web.Helpers; @@ -23,7 +22,6 @@ public class PurchaseOrdersController : Controller private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly ILogger _logger; - private readonly ApplicationDbContext _context; private readonly IPdfService _pdfService; public PurchaseOrdersController( @@ -31,14 +29,12 @@ public class PurchaseOrdersController : Controller IMapper mapper, UserManager userManager, ILogger logger, - ApplicationDbContext context, IPdfService pdfService) { _unitOfWork = unitOfWork; _mapper = mapper; _userManager = userManager; _logger = logger; - _context = context; _pdfService = pdfService; } @@ -69,55 +65,10 @@ public class PurchaseOrdersController : Controller pageSize = Math.Clamp(pageSize, 10, 100); pageNumber = Math.Max(1, pageNumber); - var query = _context.Set() - .Include(po => po.Vendor) - .Include(po => po.Items.Where(i => !i.IsDeleted)) - .Where(po => !po.IsDeleted && po.CompanyId == currentUser.CompanyId) - .AsQueryable(); - - if (statusFilter.HasValue) - query = query.Where(po => po.Status == statusFilter.Value); - - if (vendorId.HasValue) - query = query.Where(po => po.VendorId == vendorId.Value); - - if (dateFrom.HasValue) - query = query.Where(po => po.OrderDate >= dateFrom.Value); - - if (dateTo.HasValue) - query = query.Where(po => po.OrderDate <= dateTo.Value.AddDays(1)); - - if (!string.IsNullOrWhiteSpace(searchTerm)) - { - var term = searchTerm.Trim().ToLower(); - query = query.Where(po => - po.PoNumber.ToLower().Contains(term) || - po.Vendor.CompanyName.ToLower().Contains(term) || - (po.Notes != null && po.Notes.ToLower().Contains(term))); - } - - query = (sortColumn?.ToLower(), sortDirection?.ToLower()) switch - { - ("ponumber", "asc") => query.OrderBy(po => po.PoNumber), - ("ponumber", _) => query.OrderByDescending(po => po.PoNumber), - ("vendor", "asc") => query.OrderBy(po => po.Vendor.CompanyName), - ("vendor", _) => query.OrderByDescending(po => po.Vendor.CompanyName), - ("status", "asc") => query.OrderBy(po => po.Status), - ("status", _) => query.OrderByDescending(po => po.Status), - ("orderdate", "asc") => query.OrderBy(po => po.OrderDate), - ("orderdate", _) => query.OrderByDescending(po => po.OrderDate), - ("expected", "asc") => query.OrderBy(po => po.ExpectedDeliveryDate), - ("expected", _) => query.OrderByDescending(po => po.ExpectedDeliveryDate), - ("total", "asc") => query.OrderBy(po => po.TotalAmount), - ("total", _) => query.OrderByDescending(po => po.TotalAmount), - _ => query.OrderByDescending(po => po.OrderDate) - }; - - var totalCount = await query.CountAsync(); - var items = await query - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); + var (items, totalCount) = await _unitOfWork.PurchaseOrders.GetPagedAsync( + currentUser.CompanyId, pageNumber, pageSize, + statusFilter, vendorId, dateFrom, dateTo, + searchTerm, sortColumn, sortDirection); var dtos = _mapper.Map>(items); @@ -129,24 +80,12 @@ public class PurchaseOrdersController : Controller PageSize = pageSize }; - // Stats - var allForStats = await _context.Set() - .Where(po => !po.IsDeleted && po.CompanyId == currentUser.CompanyId) - .Select(po => new { po.Status, po.TotalAmount, po.ExpectedDeliveryDate }) - .ToListAsync(); - - ViewBag.TotalCount = allForStats.Count; - ViewBag.OpenCount = allForStats.Count(p => - p.Status == PurchaseOrderStatus.Draft || - p.Status == PurchaseOrderStatus.Submitted || - p.Status == PurchaseOrderStatus.PartiallyReceived); - ViewBag.CommittedValue = allForStats - .Where(p => p.Status != PurchaseOrderStatus.Cancelled) - .Sum(p => p.TotalAmount); - ViewBag.OverdueCount = allForStats.Count(p => - (p.Status == PurchaseOrderStatus.Draft || p.Status == PurchaseOrderStatus.Submitted || p.Status == PurchaseOrderStatus.PartiallyReceived) - && p.ExpectedDeliveryDate.HasValue - && p.ExpectedDeliveryDate.Value.Date < DateTime.UtcNow.Date); + // Stats (server-side projection — only three columns fetched) + var stats = await _unitOfWork.PurchaseOrders.GetStatsAsync(currentUser.CompanyId); + ViewBag.TotalCount = stats.TotalCount; + ViewBag.OpenCount = stats.OpenCount; + ViewBag.CommittedValue = stats.CommittedValue; + ViewBag.OverdueCount = stats.OverdueCount; await PopulateVendorFilterDropdownAsync(currentUser.CompanyId); ViewBag.SearchTerm = searchTerm; @@ -172,13 +111,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Vendor) - .Include(p => p.Bill) - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.InventoryItem) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); - + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); var dto = _mapper.Map(po); @@ -283,10 +216,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); - + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft) { @@ -335,10 +265,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); - + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft) { @@ -416,10 +343,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Vendor) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); - + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft && po.Status != PurchaseOrderStatus.Cancelled) @@ -487,10 +411,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); - + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); if (po.Status != PurchaseOrderStatus.Draft) @@ -560,11 +481,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Vendor) - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.InventoryItem) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); @@ -618,11 +535,7 @@ public class PurchaseOrdersController : Controller var currentUser = await _userManager.GetUserAsync(User); if (currentUser == null) return Unauthorized(); - var po = await _context.Set() - .Include(p => p.Vendor) - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.InventoryItem) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); @@ -692,7 +605,7 @@ public class PurchaseOrdersController : Controller CompanyId = po.CompanyId }; - await _context.Set().AddAsync(transaction); + await _unitOfWork.InventoryTransactions.AddAsync(transaction); } } @@ -720,9 +633,8 @@ public class PurchaseOrdersController : Controller } po.UpdatedAt = DateTime.UtcNow; - await _context.SaveChangesAsync(); - }); // end ExecuteInTransactionAsync + }); // end ExecuteInTransactionAsync — SaveChangesAsync called automatically before commit this.ToastSuccess(allReceived ? $"All items received for {po.PoNumber}." @@ -754,11 +666,7 @@ public class PurchaseOrdersController : Controller try { - var po = await _context.Set() - .Include(p => p.Vendor) - .Include(p => p.Items.Where(i => !i.IsDeleted)) - .ThenInclude(i => i.InventoryItem) - .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted && p.CompanyId == currentUser.CompanyId); + var po = await _unitOfWork.PurchaseOrders.LoadForViewAsync(id, currentUser.CompanyId); if (po == null) return NotFound(); @@ -806,14 +714,12 @@ public class PurchaseOrdersController : Controller if (currentUser == null) return Unauthorized(); // Find low-stock items that have a primary vendor - var lowStockItems = await _context.Set() - .Include(i => i.PrimaryVendor) - .Where(i => !i.IsDeleted - && i.IsActive - && i.CompanyId == currentUser.CompanyId - && i.PrimaryVendorId != null - && i.QuantityOnHand <= i.ReorderPoint) - .ToListAsync(); + var lowStockItems = (await _unitOfWork.InventoryItems.FindAsync( + i => i.IsActive && i.CompanyId == currentUser.CompanyId && + i.PrimaryVendorId != null && i.QuantityOnHand <= i.ReorderPoint, + false, + i => i.PrimaryVendor!)) + .ToList(); if (!lowStockItems.Any()) { @@ -878,11 +784,10 @@ public class PurchaseOrdersController : Controller { var prefix = $"PO-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-"; - var existing = await _context.Set() - .IgnoreQueryFilters() - .Where(po => po.CompanyId == companyId && po.PoNumber.StartsWith(prefix)) - .Select(po => po.PoNumber) - .ToListAsync(); + var existingPos = await _unitOfWork.PurchaseOrders.FindAsync( + po => po.CompanyId == companyId && po.PoNumber.StartsWith(prefix), + ignoreQueryFilters: true); + var existing = existingPos.Select(po => po.PoNumber).ToList(); var maxNum = 0; foreach (var num in existing) @@ -903,17 +808,18 @@ public class PurchaseOrdersController : Controller /// private async Task PopulateCreateViewBagAsync(int companyId) { - var vendors = await _context.Set() - .Where(v => !v.IsDeleted && v.CompanyId == companyId && v.IsActive) + var vendorEntities = await _unitOfWork.Vendors.FindAsync( + v => v.CompanyId == companyId && v.IsActive); + var vendors = vendorEntities .OrderBy(v => v.CompanyName) .Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())) - .ToListAsync(); - + .ToList(); vendors.Insert(0, new SelectListItem("— Select Vendor —", "")); ViewBag.Vendors = vendors; - var inventoryItems = await _context.Set() - .Where(i => !i.IsDeleted && i.CompanyId == companyId && i.IsActive) + var inventoryEntities = await _unitOfWork.InventoryItems.FindAsync( + i => i.CompanyId == companyId && i.IsActive); + var inventoryItems = inventoryEntities .OrderBy(i => i.Name) .Select(i => new { @@ -922,8 +828,7 @@ public class PurchaseOrdersController : Controller uom = i.UnitOfMeasure ?? "units", cost = i.LastPurchasePrice > 0 ? i.LastPurchasePrice : i.UnitCost }) - .ToListAsync(); - + .ToList(); ViewBag.InventoryItemsJson = System.Text.Json.JsonSerializer.Serialize(inventoryItems); } @@ -934,12 +839,11 @@ public class PurchaseOrdersController : Controller /// private async Task PopulateVendorFilterDropdownAsync(int companyId) { - var vendors = await _context.Set() - .Where(v => !v.IsDeleted && v.CompanyId == companyId) + var vendorEntities = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId); + var vendors = vendorEntities .OrderBy(v => v.CompanyName) .Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())) - .ToListAsync(); - + .ToList(); vendors.Insert(0, new SelectListItem("All Vendors", "")); ViewBag.VendorList = vendors; } 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/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index b325792..e83fa92 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.AI; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Quote; @@ -15,7 +14,6 @@ using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; -using PowderCoating.Infrastructure.Data; using PowderCoating.Web.Extensions; using PowderCoating.Web.Helpers; @@ -30,7 +28,6 @@ public class QuotesController : Controller private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IPdfService _pdfService; - private readonly ApplicationDbContext _context; private readonly ITenantContext _tenantContext; private readonly IMeasurementConversionService _measurementService; private readonly ILookupCacheService _lookupCache; @@ -51,7 +48,6 @@ public class QuotesController : Controller UserManager userManager, ILogger logger, IPdfService pdfService, - ApplicationDbContext context, ITenantContext tenantContext, IMeasurementConversionService measurementService, ILookupCacheService lookupCache, @@ -71,7 +67,6 @@ public class QuotesController : Controller _userManager = userManager; _logger = logger; _pdfService = pdfService; - _context = context; _tenantContext = tenantContext; _measurementService = measurementService; _lookupCache = lookupCache; @@ -234,11 +229,10 @@ public class QuotesController : Controller var approvedConvertedIds = quoteStatuses .Where(s => s.StatusCode == "APPROVED" || s.StatusCode == "CONVERTED") .Select(s => s.Id).ToList(); - var allCompanyQuotes = _context.Quotes - .Where(q => q.CompanyId == companyId && !q.IsDeleted); - ViewBag.StatOpenCount = await allCompanyQuotes.CountAsync(q => draftSentIds.Contains(q.QuoteStatusId)); - ViewBag.StatApprovedCount = await allCompanyQuotes.CountAsync(q => approvedConvertedIds.Contains(q.QuoteStatusId)); - ViewBag.StatTotalValue = await allCompanyQuotes.SumAsync(q => (decimal?)q.Total) ?? 0m; + var indexStats = await _unitOfWork.Quotes.GetIndexStatsAsync(draftSentIds, approvedConvertedIds); + ViewBag.StatOpenCount = indexStats.OpenCount; + ViewBag.StatApprovedCount = indexStats.ApprovedConvertedCount; + ViewBag.StatTotalValue = indexStats.TotalValue; // Calibration nudge — suppress when named blast setups exist OR legacy CFM is set var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault(); @@ -266,7 +260,7 @@ public class QuotesController : Controller /// the customer even if operating costs have changed since the quote was created. /// Also verifies that ConvertedToJobId still points to a live job (clears stale references). /// - public async Task Details(int? id) + public async Task Details(int? id, string? guidedActivation = null) { if (id == null) { @@ -275,32 +269,14 @@ public class QuotesController : Controller try { - // Load quote with items and their related entities - var quote = await _unitOfWork.Quotes.GetByIdAsync( - id.Value, - false, - q => q.QuoteItems, - q => q.Customer, - q => q.PreparedBy, - q => q.QuoteStatus, - q => q.QuotePrepServices, - q => q.OvenCost - ); + // Load quote with all navigations needed for the Details view + var quote = await _unitOfWork.Quotes.LoadForDetailsAsync(id.Value); if (quote == null) { return NotFound(); } - // Load prep services with their details - var quotePrepServices = await _context.QuotePrepServices - .Where(qps => qps.QuoteId == id.Value && !qps.IsDeleted) - .Include(qps => qps.PrepService) - .ToListAsync(); - - // Assign to quote so AutoMapper can map them - quote.QuotePrepServices = quotePrepServices; - var quoteDto = _mapper.Map(quote); // Get customer info if exists @@ -353,19 +329,7 @@ public class QuotesController : Controller } } - // Get quote items with their related entities (Coats, CatalogItem, and PrepServices) - var quoteItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted) - .Include(qi => qi.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(qi => qi.Coats) - .ThenInclude(c => c.Vendor) - .Include(qi => qi.CatalogItem) - .Include(qi => qi.PrepServices) - .ThenInclude(ps => ps.PrepService) - .ToListAsync(); - - quoteDto.QuoteItems = _mapper.Map>(quoteItems); + quoteDto.QuoteItems = _mapper.Map>(quote.QuoteItems); // DEBUG: Log coat data _logger.LogInformation($"=== DETAILS VIEW: Quote {id} has {quoteDto.QuoteItems.Count} items ==="); @@ -427,13 +391,7 @@ public class QuotesController : Controller } // Load change history - var changeHistories = await _context.QuoteChangeHistories - .Where(h => h.QuoteId == id.Value && !h.IsDeleted) - .Include(h => h.ChangedBy) - .OrderByDescending(h => h.ChangedAt) - .AsNoTracking() - .ToListAsync(); - + var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value); var changeHistoryDtos = _mapper.Map>(changeHistories); ViewBag.ChangeHistory = changeHistoryDtos; @@ -458,12 +416,9 @@ public class QuotesController : Controller await PopulateEmailNotificationDefaultsAsync(currentUserDetails.CompanyId); // Load deposits recorded against this quote - var quoteDeposits = await _context.Set() - .Where(d => !d.IsDeleted && d.CompanyId == quote.CompanyId && d.QuoteId == id.Value) - .Include(d => d.RecordedBy) + var quoteDeposits = (await _unitOfWork.Deposits.FindAsync(d => d.QuoteId == id.Value, false, d => d.RecordedBy)) .OrderByDescending(d => d.ReceivedDate) - .AsNoTracking() - .ToListAsync(); + .ToList(); ViewBag.Deposits = quoteDeposits; // Customer list for inline customer-change dropdown @@ -480,6 +435,21 @@ public class QuotesController : Controller .OrderBy(c => c.Text) .ToList(); + var quotePrefs = await GetCompanyPreferencesAsync(currentUser!.CompanyId); + if (guidedActivation == AppConstants.GuidedActivation.QuoteCreatedStep + && quotePrefs?.OnboardingPath == AppConstants.GuidedActivation.QuoteFirstPath + && quotePrefs.FirstWorkflowCompleted == false) + { + ViewBag.GuidedActivationMode = AppConstants.GuidedActivation.QuoteFirstPath; + ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel + { + Show = true, + Title = "This is the quote you would send to your customer.", + Message = "Next, convert it into a job so it moves into your real production workflow.", + ActionText = "Convert to Job" + }; + } + return View(quoteDto); } catch (Exception ex) @@ -564,16 +534,7 @@ public class QuotesController : Controller } } - // Get quote items with their related entities (Coats and CatalogItem) - var quoteItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted) - .Include(qi => qi.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(qi => qi.Coats) - .ThenInclude(c => c.Vendor) - .Include(qi => qi.CatalogItem) - .ToListAsync(); - + var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); quoteDto.QuoteItems = _mapper.Map>(quoteItems); // Get company info and logo @@ -632,10 +593,8 @@ public class QuotesController : Controller }; // Load company preferences for PDF template settings - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .AsNoTracking() - .FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted, ignoreQueryFilters: true); var template = new Application.DTOs.Company.QuoteTemplateSettingsDto { @@ -683,7 +642,7 @@ public class QuotesController : Controller /// Optionally pre-selects a customer when is provided (e.g. when /// navigating from the Customer Details page using the "New Quote" shortcut). /// - public async Task Create(int? customerId) + public async Task Create(int? customerId, string? guidedActivation = null) { try { @@ -738,6 +697,17 @@ public class QuotesController : Controller CustomerId = customerId }; + if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath) + { + var draft = GuidedActivationDefaults.BuildQuoteDraft(customerId); + dto.CustomerId = draft.CustomerId; + dto.Description = draft.Description; + dto.Notes = draft.Notes; + dto.QuoteItems = draft.QuoteItems; + } + + ViewBag.GuidedActivation = guidedActivation; + return View(dto); } catch (Exception ex) @@ -759,7 +729,7 @@ public class QuotesController : Controller /// [HttpPost] [ValidateAntiForgeryToken] - public async Task Create(CreateQuoteDto dto) + public async Task Create(CreateQuoteDto dto, string? guidedActivation = null) { _logger.LogInformation("=== CREATE QUOTE POST ACTION CALLED ==="); _logger.LogInformation("IsForProspect: {IsForProspect}", dto.IsForProspect); @@ -888,6 +858,7 @@ public class QuotesController : Controller await PopulateDropDownsAsync(currentUser!.CompanyId, operatingCosts?.OvenOperatingCostPerHour ?? 0); await SetMeasurementViewBagAsync(); + ViewBag.GuidedActivation = guidedActivation; return View(dto); } @@ -1186,15 +1157,22 @@ public class QuotesController : Controller _logger.LogWarning(ex, "Notification failed for quote {Id}", quote.Id); } - var quoteCreateNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.QuoteId == quote.Id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id); this.SetNotificationResultToast(quoteCreateNotifLog); } + await StampQuoteCreatedAsync(currentUser.CompanyId); + this.ToastSuccess($"Quote {quote.QuoteNumber} created successfully!"); + if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath) + { + return RedirectToAction(nameof(Details), new + { + id = quote.Id, + guidedActivation = AppConstants.GuidedActivation.QuoteCreatedStep + }); + } + return RedirectToAction(nameof(Details), new { id = quote.Id }); } catch (Exception ex) @@ -1205,6 +1183,7 @@ public class QuotesController : Controller var catchCosts = await _pricingService.GetOperatingCostsAsync(catchUser!.CompanyId); await PopulateDropDownsAsync(catchUser.CompanyId, catchCosts?.OvenOperatingCostPerHour ?? 0); await SetMeasurementViewBagAsync(); + ViewBag.GuidedActivation = guidedActivation; return View(dto); } } @@ -1231,13 +1210,7 @@ public class QuotesController : Controller } // Get quote items with their coats, prep services and catalog item - var quoteItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted) - .Include(qi => qi.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(qi => qi.PrepServices) - .Include(qi => qi.CatalogItem) - .ToListAsync(); + var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); _logger.LogInformation("=== LOADING QUOTE {QuoteId} FOR EDIT ===", id.Value); foreach (var item in quoteItems) @@ -2166,7 +2139,7 @@ public class QuotesController : Controller /// [HttpPost] [ValidateAntiForgeryToken] - public async Task ConvertToJob(int id) + public async Task ConvertToJob(int id, string? guidedActivation = null) { try { @@ -2230,15 +2203,13 @@ public class QuotesController : Controller // Check for an orphaned partial job (previous conversion attempt that failed mid-way). // This can happen if SaveChangesAsync succeeded for the Job row but failed for JobItems. // The unique index on Jobs.QuoteId would block a retry — clean it up first. - var orphanedJob = await _context.Jobs - .IgnoreQueryFilters() - .FirstOrDefaultAsync(j => j.QuoteId == id && j.CompanyId == quote.CompanyId); + var orphanedJob = await _unitOfWork.Jobs.GetOrphanedConversionJobAsync(id, quote.CompanyId); if (orphanedJob != null) { _logger.LogWarning("Found orphaned job {JobNumber} (Id={JobId}) from a previous failed conversion of quote {QuoteId}. Cleaning up.", orphanedJob.JobNumber, orphanedJob.Id, id); - _context.Jobs.Remove(orphanedJob); - await _context.SaveChangesAsync(); + await _unitOfWork.Jobs.DeleteAsync(orphanedJob); + await _unitOfWork.CompleteAsync(); } var currentUser = await _userManager.GetUserAsync(User); @@ -2299,9 +2270,20 @@ public class QuotesController : Controller this.ToastSuccess($"Job has been successfully created from quote {quote.QuoteNumber}!"); + await StampJobCreatedAsync(currentUser!.CompanyId); + // Redirect to the newly created job's details page if (quote.ConvertedToJobId.HasValue) { + if (guidedActivation == AppConstants.GuidedActivation.QuoteFirstPath) + { + return RedirectToAction("Details", "Jobs", new + { + id = quote.ConvertedToJobId.Value, + guidedActivation = AppConstants.GuidedActivation.JobCreatedStep + }); + } + return RedirectToAction("Details", "Jobs", new { id = quote.ConvertedToJobId.Value }); } @@ -2351,25 +2333,11 @@ public class QuotesController : Controller } } - // Get quote items with their related entities (Coats and CatalogItem) - var quoteItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted) - .Include(qi => qi.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(qi => qi.Coats) - .ThenInclude(c => c.Vendor) - .Include(qi => qi.CatalogItem) - .ToListAsync(); - + var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); quoteDto.QuoteItems = _mapper.Map>(quoteItems); // Warn on confirmation page if a job is linked - var linkedJob = await _context.Jobs - .AsNoTracking() - .Where(j => j.QuoteId == id.Value && !j.IsDeleted) - .Select(j => new { j.Id, j.JobNumber }) - .FirstOrDefaultAsync(); - + var linkedJob = await _unitOfWork.Jobs.FirstOrDefaultAsync(j => j.QuoteId == id.Value); if (linkedJob != null) { ViewBag.LinkedJobId = linkedJob.Id; @@ -2403,12 +2371,7 @@ public class QuotesController : Controller } // Block deletion if a job was created from this quote - var linkedJob = await _context.Jobs - .AsNoTracking() - .Where(j => j.QuoteId == id && !j.IsDeleted) - .Select(j => new { j.Id, j.JobNumber }) - .FirstOrDefaultAsync(); - + var linkedJob = await _unitOfWork.Jobs.FirstOrDefaultAsync(j => j.QuoteId == id); if (linkedJob != null) { this.ToastError($"Quote {quote.QuoteNumber} cannot be deleted because Job {linkedJob.JobNumber} was created from it. Delete the job first, or keep the quote as a record."); @@ -2465,8 +2428,8 @@ public class QuotesController : Controller var currentUser = await _userManager.GetUserAsync(User); // Find the Approved status for this company - var approvedStatus = await _context.QuoteStatusLookups - .FirstOrDefaultAsync(s => s.StatusCode == "APPROVED" && s.CompanyId == currentUser!.CompanyId); + var approvedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync( + s => s.StatusCode == "APPROVED" && s.CompanyId == currentUser!.CompanyId); if (approvedStatus == null) { @@ -2518,11 +2481,7 @@ public class QuotesController : Controller _logger.LogWarning(ex, "Notification failed for quote {Id}", id); } - var approveNotifLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.QuoteId == id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id); this.SetNotificationResultToast(approveNotifLog); } @@ -2642,6 +2601,11 @@ public class QuotesController : Controller .Where(c => c.IsTaxExempt) .Select(c => c.Id) .ToHashSet(); + // Map used by JS to disable the email checkbox when the customer has notifications turned off + ViewBag.CustomerEmailOptOutIds = customers + .Where(c => !c.NotifyByEmail) + .Select(c => c.Id) + .ToHashSet(); // Stored separately so views can restore the company default when switching away from an exempt customer // (ViewBag.CompanyTaxPercent is set by the calling action if it has access to operatingCosts) if (ViewBag.CompanyTaxPercent == null && customers.Any()) @@ -2761,9 +2725,8 @@ public class QuotesController : Controller /// private async Task SetDefaultTermsAsync(int companyId) { - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters().AsNoTracking() - .FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); ViewBag.DefaultTerms = prefs?.QtDefaultTerms; var costs = await _pricingService.GetOperatingCostsAsync(companyId); @@ -2836,13 +2799,7 @@ public class QuotesController : Controller } } - var quoteItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == quoteId && !qi.IsDeleted) - .Include(qi => qi.Coats).ThenInclude(c => c.InventoryItem) - .Include(qi => qi.Coats).ThenInclude(c => c.Vendor) - .Include(qi => qi.CatalogItem) - .ToListAsync(); - + var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId); quoteDto.QuoteItems = _mapper.Map>(quoteItems); var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId); @@ -2859,10 +2816,8 @@ public class QuotesController : Controller PrimaryContactEmail = company.PrimaryContactEmail }; - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .AsNoTracking() - .FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted, ignoreQueryFilters: true); var template = new Application.DTOs.Company.QuoteTemplateSettingsDto { @@ -2890,23 +2845,14 @@ public class QuotesController : Controller var now = DateTime.UtcNow; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - 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}"; // IgnoreQueryFilters so soft-deleted quotes are counted (prevents number reuse) - // Explicit CompanyId filter scopes to current company only - 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) @@ -3012,15 +2958,7 @@ public class QuotesController : Controller // Always reload quote items with full coat/prep-service data so this works // regardless of which caller loaded the quote (some callers don't include coats). - var fullItems = await _context.QuoteItems - .Where(qi => qi.QuoteId == quote.Id && !qi.IsDeleted) - .Include(qi => qi.Coats) - .ThenInclude(c => c.InventoryItem) - .Include(qi => qi.Coats) - .ThenInclude(c => c.Vendor) - .Include(qi => qi.PrepServices) - .AsNoTracking() - .ToListAsync(); + var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quote.Id); // Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning // no-tracking children (which may share InventoryItem instances) causes EF identity conflicts. @@ -3161,9 +3099,8 @@ public class QuotesController : Controller // Aggregate unique prep services from all quote items and copy to job // Load from DB directly to ensure prep services are available regardless of caller's includes var quoteItemIds = fullItems.Select(qi => qi.Id).ToList(); - var itemPrepServices = await _context.QuoteItemPrepServices - .Where(ps => quoteItemIds.Contains(ps.QuoteItemId) && !ps.IsDeleted) - .ToListAsync(); + var itemPrepServices = (await _unitOfWork.QuoteItemPrepServices.FindAsync( + ps => quoteItemIds.Contains(ps.QuoteItemId))).ToList(); var uniquePrepServiceIds = itemPrepServices .Select(ps => ps.PrepServiceId) .Distinct() @@ -3173,7 +3110,7 @@ public class QuotesController : Controller { foreach (var prepServiceId in uniquePrepServiceIds) { - await _context.JobPrepServices.AddAsync(new JobPrepService + await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService { JobId = job.Id, PrepServiceId = prepServiceId, @@ -3181,7 +3118,7 @@ public class QuotesController : Controller CreatedAt = DateTime.UtcNow }); } - await _context.SaveChangesAsync(); + await _unitOfWork.SaveChangesAsync(); _logger.LogInformation("Copied {Count} unique prep services to job {JobNumber}", uniquePrepServiceIds.Count, job.JobNumber); } @@ -3195,10 +3132,8 @@ public class QuotesController : Controller // AI analysis photos are copied with IsAiAnalysisPhoto=true so they don't count against subscription limits try { - var quotePhotos = await _context.QuotePhotos - .IgnoreQueryFilters() - .Where(p => p.QuoteId == quote.Id && !p.IsDeleted) - .ToListAsync(); + var quotePhotos = (await _unitOfWork.QuotePhotos.FindAsync( + p => p.QuoteId == quote.Id && !p.IsDeleted, ignoreQueryFilters: true)).ToList(); foreach (var qp in quotePhotos) { @@ -3218,7 +3153,7 @@ public class QuotesController : Controller if (saved) { - await _context.JobPhotos.AddAsync(new JobPhoto + await _unitOfWork.JobPhotos.AddAsync(new JobPhoto { JobId = job.Id, CompanyId = quote.CompanyId, @@ -3234,7 +3169,7 @@ public class QuotesController : Controller }); } } - await _context.SaveChangesAsync(); + await _unitOfWork.SaveChangesAsync(); } catch (Exception photoEx) { @@ -3258,23 +3193,14 @@ public class QuotesController : Controller var month = DateTime.Now.Month.ToString("D2"); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - var prefs = await _context.CompanyPreferences - .IgnoreQueryFilters() - .Where(p => p.CompanyId == companyId && !p.IsDeleted) - .Select(p => new { p.JobNumberPrefix }) - .FirstOrDefaultAsync(); + var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync( + p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true); var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB"; var prefix = $"{jobPrefix}-{year}{month}"; // IgnoreQueryFilters so soft-deleted jobs are counted (prevents number reuse) - // Explicit CompanyId filter scopes to current company only - var lastJobNumber = await _context.Jobs - .IgnoreQueryFilters() - .Where(j => j.CompanyId == companyId && j.JobNumber.StartsWith(prefix)) - .OrderByDescending(j => j.JobNumber) - .Select(j => j.JobNumber) - .FirstOrDefaultAsync(); + var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix); int nextNumber = 1; if (lastJobNumber != null) @@ -3347,11 +3273,7 @@ public class QuotesController : Controller await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename); // Check the most recent log entry to get actual send status - var latestLog = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.QuoteId == id) - .OrderByDescending(n => n.SentAt) - .FirstOrDefaultAsync(); + var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id); if (latestLog?.Status == NotificationStatus.Failed) return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" }); @@ -3378,16 +3300,10 @@ public class QuotesController : Controller public async Task NotificationsSent(int id) { var tz = ViewBag.CompanyTimeZone as string; - var raw = await _context.NotificationLogs - .IgnoreQueryFilters() - .Where(n => n.QuoteId == id) - .OrderByDescending(n => n.SentAt) - .Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), - Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.SentAt }) - .ToListAsync(); - - var logs = raw.Select(n => new { n.Id, n.Channel, n.Type, n.Status, n.RecipientName, - n.Recipient, n.Subject, n.ErrorMessage, SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); + var rawLogs = await _unitOfWork.NotificationLogs.GetAllForQuoteAsync(id); + var logs = rawLogs.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), + Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, + SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); return Json(logs); } @@ -3925,6 +3841,35 @@ public class QuotesController : Controller return Json(new { success = true }); } + + private async Task GetCompanyPreferencesAsync(int companyId) + { + return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); + } + + private async Task StampQuoteCreatedAsync(int companyId) + { + var prefs = await GetCompanyPreferencesAsync(companyId); + if (prefs == null || prefs.FirstQuoteCreatedAt.HasValue) + return; + + prefs.FirstQuoteCreatedAt = DateTime.UtcNow; + await _unitOfWork.CompleteAsync(); + + _logger.LogInformation("Recorded first quote creation for company {CompanyId}", companyId); + } + + private async Task StampJobCreatedAsync(int companyId) + { + var prefs = await GetCompanyPreferencesAsync(companyId); + if (prefs == null || prefs.FirstJobCreatedAt.HasValue) + return; + + prefs.FirstJobCreatedAt = DateTime.UtcNow; + await _unitOfWork.CompleteAsync(); + + _logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId); + } } // Request model for AJAX pricing calculation 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..2a60e17 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(); } @@ -315,31 +311,7 @@ public class SetupWizardController : Controller ShopCapabilityTier = costs.ShopCapabilityTier }), 4 => await BuildStep4ViewAsync(GetCompanyId()), - 5 => View("Step5", new WizardStep3Dto - { - QuoteNumberPrefix = prefs.QuoteNumberPrefix, - JobNumberPrefix = prefs.JobNumberPrefix, - InvoiceNumberPrefix = !string.IsNullOrWhiteSpace(prefs.InvoiceNumberPrefix) ? prefs.InvoiceNumberPrefix : "INV", - QtAccentColor = prefs.QtAccentColor, - InAccentColor = prefs.InAccentColor, - WoAccentColor = prefs.WoAccentColor - }), - 6 => View("Step6", new WizardStep5Dto - { - DefaultJobPriority = prefs.DefaultJobPriority, - RequireCustomerPO = prefs.RequireCustomerPO, - AllowCustomerApproval = prefs.AllowCustomerApproval, - }), - 7 => View("Step7", new WizardStep4Dto - { - DefaultPaymentTerms = prefs.DefaultPaymentTerms, - DefaultQuoteValidityDays = prefs.DefaultQuoteValidityDays, - DefaultTurnaroundDays = prefs.DefaultTurnaroundDays, - QtDefaultTerms = prefs.QtDefaultTerms, - QtFooterNote = prefs.QtFooterNote - }), - 8 => await BuildStep8ViewAsync(GetCompanyId()), - 9 => View("Step9", new WizardStep7Dto + 5 => View("Step9", new WizardStep7Dto { EmailNotificationsEnabled = prefs.EmailNotificationsEnabled, EmailFromAddress = prefs.EmailFromAddress, @@ -355,7 +327,6 @@ public class SetupWizardController : Controller DueDateWarningDays = prefs.DueDateWarningDays, MaintenanceAlertDays = prefs.MaintenanceAlertDays }), - 10 => await BuildStep10ViewAsync(GetCompanyId()), _ => RedirectToAction("Step", new { step = 1 }) }; } @@ -409,53 +380,6 @@ public class SetupWizardController : Controller return View("Step4", dto); } - /// - /// Builds the view model for Step 8 (Pricing Tiers) by loading existing tiers and serializing - /// them as camelCase JSON for the client-side tier management table. - /// CamelCase serialization is required here because the JavaScript that reads this JSON expects - /// camelCase property names (e.g., tierName not TierName), unlike the oven step - /// which uses PascalCase — a discrepancy inherited from different JS widget implementations. - /// - private async Task BuildStep8ViewAsync(int companyId) - { - var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && !t.IsDeleted); - var camelCase = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - var dto = new WizardPricingTiersStepDto - { - TiersJson = existing.Any() - ? JsonSerializer.Serialize(existing.OrderBy(t => t.Id).Select(t => new WizardPricingTierDto - { - Id = t.Id, - TierName = t.TierName, - Description = t.Description, - DiscountPercent = t.DiscountPercent - }), camelCase) - : null - }; - return View("Step8", dto); - } - - /// - /// Builds the view model for Step 10 (Team Members) by loading existing non-admin users so they - /// can be displayed as read-only in the view. - /// Only non-admin company users are shown because the wizard's team-member step is designed for - /// adding shop workers and managers; the CompanyAdmin who is running the wizard is already - /// implied. Showing existing members prevents the wizard user from accidentally creating - /// duplicates of accounts that were added outside the wizard flow. - /// - private async Task BuildStep10ViewAsync(int companyId) - { - // Load existing non-admin team members so they're shown as read-only in the view - var existingUsers = await _userManager.Users - .Where(u => u.CompanyId == companyId && u.IsActive - && u.CompanyRole != AppConstants.CompanyRoles.CompanyAdmin) - .OrderBy(u => u.LastName).ThenBy(u => u.FirstName) - .Select(u => new { u.FirstName, u.LastName, u.Email, u.CompanyRole }) - .ToListAsync(); - ViewBag.ExistingTeamMembers = existingUsers; - return View("Step10", new WizardStep9Dto()); - } - // ─── POST Steps ─────────────────────────────────────────────────────────── /// @@ -679,138 +603,15 @@ public class SetupWizardController : Controller return RedirectToStep(5); } - [HttpPost, ValidateAntiForgeryToken] - public async Task PostStep5(WizardStep3Dto model) - { - var (_, prefs, _) = await LoadCompanyDataAsync(); - ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 5; - - if (!ModelState.IsValid) return View("Step5", model); - - prefs.QuoteNumberPrefix = model.QuoteNumberPrefix; - prefs.JobNumberPrefix = model.JobNumberPrefix; - prefs.InvoiceNumberPrefix = model.InvoiceNumberPrefix; - prefs.QtAccentColor = model.QtAccentColor; - prefs.InAccentColor = model.InAccentColor; - prefs.WoAccentColor = model.WoAccentColor; - - MarkDone(prefs, 5); - await _unitOfWork.CompleteAsync(); - return RedirectToStep(6); - } - - [HttpPost, ValidateAntiForgeryToken] - public async Task PostStep6(WizardStep5Dto model) - { - var (_, prefs, _) = await LoadCompanyDataAsync(); - ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 6; - - if (!ModelState.IsValid) return View("Step6", model); - - prefs.DefaultJobPriority = model.DefaultJobPriority; - prefs.RequireCustomerPO = model.RequireCustomerPO; - prefs.AllowCustomerApproval = model.AllowCustomerApproval; - - MarkDone(prefs, 6); - await _unitOfWork.CompleteAsync(); - return RedirectToStep(7); - } - - [HttpPost, ValidateAntiForgeryToken] - public async Task PostStep7(WizardStep4Dto model) - { - var (_, prefs, _) = await LoadCompanyDataAsync(); - ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 7; - - if (!ModelState.IsValid) return View("Step7", model); - - prefs.DefaultPaymentTerms = model.DefaultPaymentTerms; - prefs.DefaultQuoteValidityDays = model.DefaultQuoteValidityDays; - prefs.DefaultTurnaroundDays = model.DefaultTurnaroundDays; - prefs.QtDefaultTerms = model.QtDefaultTerms; - prefs.QtFooterNote = model.QtFooterNote; - - MarkDone(prefs, 7); - await _unitOfWork.CompleteAsync(); - return RedirectToStep(8); - } - /// - /// Persists pricing tiers from Step 8, using the same upsert-and-soft-delete pattern as - /// : existing tiers updated in place, new ones inserted, removed ones - /// soft-deleted. Tiers with a blank TierName are silently ignored so the client-side - /// table's empty placeholder rows do not produce invalid records. JsonException is caught and - /// logged rather than thrown so a malformed JSON payload (e.g., from a broken browser extension) - /// still advances the wizard rather than stopping the admin from completing setup. + /// Saves notification preferences from Step 5 (the final step). Marks the wizard complete + /// and hands off to the Guided Activation flow. /// - [HttpPost, ValidateAntiForgeryToken] - public async Task PostStep8(WizardPricingTiersStepDto model) - { - var (_, prefs, _) = await LoadCompanyDataAsync(); - var companyId = GetCompanyId(); - - if (!string.IsNullOrWhiteSpace(model.TiersJson)) - { - try - { - var tiers = JsonSerializer.Deserialize>(model.TiersJson, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - if (tiers != null) - { - var validTiers = tiers.Where(t => !string.IsNullOrWhiteSpace(t.TierName)).ToList(); - if (validTiers.Count > 0) - { - var existing = (await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && !t.IsDeleted)) - .ToDictionary(t => t.Id); - var submittedIds = validTiers.Where(t => t.Id > 0).Select(t => t.Id).ToHashSet(); - - // Soft-delete tiers that were removed from the list - foreach (var e in existing.Values.Where(e => !submittedIds.Contains(e.Id))) - await _unitOfWork.PricingTiers.SoftDeleteAsync(e.Id); - - foreach (var t in validTiers) - { - if (t.Id > 0 && existing.TryGetValue(t.Id, out var record)) - { - // Update in place - record.TierName = t.TierName.Trim(); - record.Description = t.Description?.Trim(); - record.DiscountPercent = t.DiscountPercent; - await _unitOfWork.PricingTiers.UpdateAsync(record); - } - else - { - await _unitOfWork.PricingTiers.AddAsync(new PricingTier - { - CompanyId = companyId, - TierName = t.TierName.Trim(), - Description = t.Description?.Trim(), - DiscountPercent = t.DiscountPercent, - IsActive = true - }); - } - } - await _unitOfWork.CompleteAsync(); - } - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize pricing tiers JSON in wizard step 8"); - } - } - - MarkDone(prefs, 8); - await _unitOfWork.CompleteAsync(); - return RedirectToStep(9); - } - [HttpPost, ValidateAntiForgeryToken] public async Task PostStep9(WizardStep7Dto model) { var (_, prefs, _) = await LoadCompanyDataAsync(); - ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 9; + ViewBag.Progress = BuildProgress(prefs); ViewBag.Step = 5; if (!ModelState.IsValid) return View("Step9", model); @@ -828,83 +629,9 @@ public class SetupWizardController : Controller prefs.DueDateWarningDays = model.DueDateWarningDays; prefs.MaintenanceAlertDays = model.MaintenanceAlertDays; - MarkDone(prefs, 9); - await _unitOfWork.CompleteAsync(); - return RedirectToStep(10); - } - - /// - /// Creates team member accounts from Step 10 (Invite Team), assigns each user a company role, - /// and also maps them to the legacy ASP.NET Identity role system for policy-based authorization. - /// The dual-role assignment (CompanyRole + Identity role) is required because authorization - /// policies in this app evaluate both the legacy role claim and the CompanyRole property. - /// Users with emails that already exist are silently skipped so re-submitting the wizard after - /// a partial failure does not attempt to create duplicates. Setting SetupWizardCompleted = true - /// here hides the wizard prompt from the dashboard going forward. - /// - [HttpPost, ValidateAntiForgeryToken] - public async Task PostStep10(WizardStep9Dto model) - { - var (_, prefs, _) = await LoadCompanyDataAsync(); - var companyId = GetCompanyId(); - - if (!string.IsNullOrWhiteSpace(model.MembersJson)) - { - try - { - var members = JsonSerializer.Deserialize>(model.MembersJson, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - - if (members != null) - { - foreach (var m in members.Where(m => !string.IsNullOrWhiteSpace(m.Email) - && !string.IsNullOrWhiteSpace(m.Password))) - { - var existing = await _userManager.FindByEmailAsync(m.Email); - if (existing != null) continue; - - var validRoles = new[] { AppConstants.CompanyRoles.CompanyAdmin, AppConstants.CompanyRoles.Manager, AppConstants.CompanyRoles.Worker, AppConstants.CompanyRoles.Viewer }; - var companyRole = validRoles.Contains(m.CompanyRole) ? m.CompanyRole : AppConstants.CompanyRoles.Worker; - - var user = new ApplicationUser - { - UserName = m.Email, Email = m.Email, EmailConfirmed = true, - FirstName = m.FirstName, LastName = m.LastName, - CompanyId = companyId, CompanyRole = companyRole, IsActive = true, - CanManageJobs = true, CanManageCustomers = true, CanCreateQuotes = true, - CanManageCalendar = true, CanViewCalendar = true, CanViewProducts = true - }; - - var result = await _userManager.CreateAsync(user, m.Password); - if (result.Succeeded) - { - var legacyRole = companyRole switch - { - AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator, - AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager, - AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee, - _ => AppConstants.Roles.ReadOnly - }; - await _userManager.AddToRoleAsync(user, legacyRole); - } - else - { - _logger.LogWarning("Failed to create wizard user {Email}: {Errors}", - m.Email, string.Join(", ", result.Errors.Select(e => e.Description))); - } - } - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize team members JSON in wizard step 10"); - } - } - - MarkDone(prefs, 10); + MarkDone(prefs, 5); prefs.SetupWizardCompleted = true; - // Record who completed the wizard and when so SuperAdmins can see completion status per-user. var currentUser = await _userManager.GetUserAsync(User); prefs.SetupWizardCompletedAt = DateTime.UtcNow; prefs.SetupWizardCompletedByUserId = currentUser?.Id; @@ -913,7 +640,7 @@ public class SetupWizardController : Controller : User.Identity?.Name; await _unitOfWork.CompleteAsync(); - return RedirectToAction(nameof(Complete)); + return RedirectToAction("Start", "GuidedActivation"); } // ─── Skip ───────────────────────────────────────────────────────────────── @@ -931,6 +658,18 @@ public class SetupWizardController : Controller { var (_, prefs, _) = await LoadCompanyDataAsync(); MarkSkipped(prefs, step); + + if (step >= WizardProgressDto.TotalSteps) + { + prefs.SetupWizardCompleted = true; + var currentUser = await _userManager.GetUserAsync(User); + prefs.SetupWizardCompletedAt = DateTime.UtcNow; + prefs.SetupWizardCompletedByUserId = currentUser?.Id; + prefs.SetupWizardCompletedByName = currentUser != null + ? $"{currentUser.FirstName} {currentUser.LastName}".Trim() + : User.Identity?.Name; + } + await _unitOfWork.CompleteAsync(); int next = step >= WizardProgressDto.TotalSteps ? 0 : step + 1; return RedirectToStep(next == 0 ? WizardProgressDto.TotalSteps + 1 : next); @@ -979,6 +718,7 @@ public class SetupWizardController : Controller { var (_, prefs, _) = await LoadCompanyDataAsync(); ViewBag.Progress = BuildProgress(prefs); + ViewBag.ShowGuidedActivationCta = prefs.SetupWizardCompleted && !prefs.FirstWorkflowCompleted; return View(); } } 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/Controllers/WebhooksController.cs b/src/PowderCoating.Web/Controllers/WebhooksController.cs index 5ed4ce2..8b30d61 100644 --- a/src/PowderCoating.Web/Controllers/WebhooksController.cs +++ b/src/PowderCoating.Web/Controllers/WebhooksController.cs @@ -22,6 +22,7 @@ public class WebhooksController : ControllerBase { private readonly ApplicationDbContext _context; private readonly IConfiguration _configuration; + private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; // CTIA-standard opt-out keywords (case-insensitive) @@ -39,10 +40,12 @@ public class WebhooksController : ControllerBase public WebhooksController( ApplicationDbContext context, IConfiguration configuration, + IWebHostEnvironment environment, ILogger logger) { _context = context; _configuration = configuration; + _environment = environment; _logger = logger; } @@ -269,16 +272,22 @@ public class WebhooksController : ControllerBase }; /// - /// Validates the incoming Twilio webhook request using HMAC-SHA1. Skips validation when the - /// auth token is unconfigured so the endpoint works in local development without real Twilio credentials. + /// Validates the incoming Twilio webhook request using HMAC-SHA1. Local development may skip + /// validation when the auth token is unconfigured, but shared environments fail closed. /// private bool ValidateTwilioRequest() { var authToken = _configuration["Twilio:AuthToken"]; if (string.IsNullOrWhiteSpace(authToken) || authToken.StartsWith("your-")) { - _logger.LogDebug("Twilio auth token not configured; skipping signature validation"); - return true; + if (_environment.IsDevelopment()) + { + _logger.LogDebug("Twilio auth token not configured in development; skipping signature validation"); + return true; + } + + _logger.LogError("Twilio auth token is not configured; rejecting webhook request"); + return false; } var signature = Request.Headers["X-Twilio-Signature"].FirstOrDefault() ?? string.Empty; diff --git a/src/PowderCoating.Web/Helpers/GuidedActivationDefaults.cs b/src/PowderCoating.Web/Helpers/GuidedActivationDefaults.cs new file mode 100644 index 0000000..c4378e4 --- /dev/null +++ b/src/PowderCoating.Web/Helpers/GuidedActivationDefaults.cs @@ -0,0 +1,54 @@ +using PowderCoating.Application.DTOs.Job; +using PowderCoating.Application.DTOs.Quote; + +namespace PowderCoating.Web.Helpers; + +internal static class GuidedActivationDefaults +{ + public static CreateQuoteDto BuildQuoteDraft(int? customerId) + { + return new CreateQuoteDto + { + CustomerId = customerId, + QuoteDate = DateTime.Today, + ExpirationDate = DateTime.Today.AddDays(30), + Description = "Sample onboarding quote", + Notes = "Sample onboarding quote", + QuoteItems = + [ + BuildSampleItem("Sample onboarding quote") + ] + }; + } + + public static CreateJobDto BuildJobDraft(int customerId, int jobPriorityId) + { + return new CreateJobDto + { + CustomerId = customerId, + JobPriorityId = jobPriorityId, + Description = "Wheel Set", + SpecialInstructions = "Sample onboarding job", + JobItems = + [ + BuildSampleItem("Sample onboarding job") + ] + }; + } + + private static CreateQuoteItemDto BuildSampleItem(string notes) + { + return new CreateQuoteItemDto + { + Description = "Wheel Set", + Quantity = 4, + SurfaceAreaSqFt = 0, + EstimatedMinutes = 0, + IsGenericItem = true, + ManualUnitPrice = 125m, + Notes = notes, + IncludePrepCost = false, + Complexity = "Moderate" + }; + } +} diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index f16f8f9..222144c 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -97,6 +97,7 @@ public static class HelpKnowledgeBase **Dashboard** → /Dashboard The main landing page after login. Shows KPIs (open jobs, pending quotes, outstanding invoices), low-stock powder alerts, equipment needing maintenance, recent activity, and rotating tips. If your company has setup gaps — missing chart of accounts, unconfigured operating costs, incomplete setup wizard, no inventory items, etc. — a color-coded alert card appears at the top of the Dashboard. Each issue badge is a clickable link that takes you directly to the page where you can fix it. Critical issues (red) affect core features like pricing and invoicing; warnings (amber) affect specific workflows; informational items are optional but recommended. The card disappears once all issues are resolved. + After the Setup Wizard is complete, a "Get the most out of your shop" progress widget appears on the Dashboard for Company Admins. It tracks six post-setup activation steps: creating a first job or quote, moving a job through the workflow, sending a first invoice, inviting the team, customizing the workflow labels, and setting payment terms. Each incomplete step shows a description and a button to the relevant page. The next recommended step is highlighted. The widget disappears automatically once all six steps are done. It can be collapsed using the chevron button in the top-right corner — the collapsed state is remembered per browser. **Operations section:** - Customers → /Customers @@ -184,12 +185,15 @@ public static class HelpKnowledgeBase - *Expired* — validity period passed - *Converted* — converted into a job + **Quick Quote vs Full Quote mode:** The New Quote form has a toggle at the top — "Quick Quote" hides non-essential fields (dates, notes, tags, oven settings, discounts, photos) so you can get a price in seconds. "Full Quote" shows the complete form. Your selection is remembered automatically. Both modes use the same pricing engine — hidden fields just use defaults. + **How to create a quote:** 1. Go to [Quotes](/Quotes) → "New Quote" - 2. Select existing customer OR enter prospect info (name, email, phone) - 3. Add line items using the item wizard (3 item types below) - 4. Review the pricing breakdown - 5. Save as Draft or Send immediately + 2. Choose Quick Quote (fast) or Full Quote (complete form) using the toggle at the top + 3. Select existing customer OR enter prospect info (name, email, phone) + 4. Add line items using the item wizard (3 item types below) + 5. Review the pricing breakdown + 6. Save as Draft or Send immediately **Three item types in the quote wizard:** 1. *Calculated* — you enter dimensions; system calculates surface area and price from operating costs @@ -220,7 +224,7 @@ public static class HelpKnowledgeBase **Prospect conversion:** If a quote was for a prospect (no existing customer), you can convert them to a customer from the Quote Details page after the quote is approved. - **Sending a quote:** Click "Send" — generates a PDF and emails it to the customer with an online approval link. + **Sending a quote:** Click "Send" — generates a PDF and emails it to the customer with an online approval link. If the customer has email notifications turned off, the send checkbox on the Create page and the Send button on Details are both disabled — a "Notifications off" warning is shown instead. **Customer approval portal:** Customers can approve/reject quotes via a public link (/QuoteApproval) — no login required. @@ -284,6 +288,8 @@ public static class HelpKnowledgeBase **Assigning workers:** Select an assigned shop worker on the Create or Edit page. Worker appears on the Details and Index views. + **Quick status change:** On the Jobs list, click any status badge to open a status-change modal without leaving the page. The modal includes a "Notify customer via email" toggle. If the customer has email notifications turned off, that toggle is automatically disabled and a warning is shown — no email will be sent. + **Job Notes:** Add internal notes on the Job Details page. Notes are private. **Time Entries:** Track labor time on a job from the Details page. @@ -574,7 +580,7 @@ public static class HelpKnowledgeBase If you are setting up Powder Coating Logix for the first time, follow this order. Each step builds on the previous one — skipping steps will cause quotes and pricing calculations to produce $0 or incorrect results. **Step 1 — Run the Setup Wizard first** - The Setup Wizard at [/SetupWizard/Step?step=1](/SetupWizard/Step?step=1) (or click the "Start Setup Wizard" button on the Dashboard) walks you through the 10 most important configuration steps in the right order. It takes about 15–20 minutes. Start here. + The Setup Wizard at [/SetupWizard/Step?step=1](/SetupWizard/Step?step=1) (or click the "Start Setup Wizard" button on the Dashboard) walks you through the 5 essential configuration steps in the right order. It takes about 5–10 minutes. Start here. **Step 2 — Add your inventory items (powders)** After the wizard, go to [Inventory](/Inventory) and add your powder coating colors as inventory items. Each item needs a cost per unit, coverage rate (sq ft/lb, default 30), and efficiency % (default 65). Items must belong to a category that has "Is Coating" checked (the wizard seeds this category for you) for them to appear in quote/job powder dropdowns. @@ -597,7 +603,7 @@ public static class HelpKnowledgeBase **Where:** [/SetupWizard](/SetupWizard/Step?step=1) — launched from the Dashboard "Start Setup Wizard" button, or directly from the URL. Only Company Admins can run it. - The Setup Wizard is a 10-step guided setup that configures every setting needed for quotes, jobs, and pricing to work correctly. It auto-seeds your chart of accounts, inventory categories, and default vendors (Prismatic Powders, Columbia Coatings) on first launch. You can skip any step and return later — progress is saved automatically. + The Setup Wizard is a 5-step guided setup that configures the essentials needed for quotes, jobs, and pricing to work correctly. It auto-seeds your chart of accounts, inventory categories, and default vendors (Prismatic Powders, Columbia Coatings) on first launch. You can skip any step and return later — progress is saved automatically. Settings not covered by the wizard (numbering prefixes, payment terms, pricing tiers, team members) can be configured any time in Company Settings. **Step 1 — Company Info** Sets your company name, address, phone, time zone, currency (USD default), and whether to use metric units. This information appears on all PDFs (quotes, invoices, work orders). Make sure the address is correct — it prints on every customer-facing document. @@ -618,31 +624,22 @@ public static class HelpKnowledgeBase **Step 4 — Named Ovens** Configure each physical oven in your shop: give it a name (e.g., "Main Oven", "Small Batch Oven"), set its cost per hour, max load capacity in sq ft, and default cure cycle in minutes. The first oven's cost per hour automatically becomes the default oven rate used in pricing calculations. You can have multiple ovens — they appear as options in the Oven Scheduler. - **Step 5 — Numbering & Branding** - Set your quote and job number prefixes (default: QT and JOB). Also set the accent color for your quote PDFs — this becomes the color of table headers and section bars on customer-facing quote documents. - - **Step 6 — Job Defaults** - - *Default Job Priority* — what priority new jobs get (Normal recommended) - - *Require Customer PO* — whether a purchase order number is required before creating a job (useful for commercial customers) - - *Allow Customer Online Approval* — whether customers can approve/reject quotes via an online link (recommended: on) - - **Step 7 — Quote Settings** - - *Default Payment Terms* — e.g., "Due on Receipt", "Net 30" — appears on quotes and invoices - - *Default Quote Validity Days* — how long quotes stay valid before expiring (30 days is typical) - - *Default Turnaround Days* — your standard lead time shown on quotes - - *Quote Terms & Conditions* — legal/policy text printed at the bottom of every quote PDF - - *Quote Footer Note* — a short footer line (e.g., "Thank you for your business!") - - **Step 8 — Pricing Tiers** - Create named discount tiers for volume or preferred customers (e.g., "Gold — 10% off", "Silver — 5% off"). These can be assigned to individual customers and are automatically applied when building quotes. You can add more tiers later at [Pricing Tiers](/PricingTiers). - - **Step 9 — Notifications** + **Step 5 — Notifications** Configure email alerts: which events trigger emails (new job, job status changes, quote approvals, payments received), how many days before due dates to send warnings, and whether to enable automatic payment reminders. Set the "From" email address and display name for outgoing emails. - **Step 10 — Team Members** - Invite your staff. Enter their name, email, password, and role (CompanyAdmin, Manager, Worker, or Viewer). They receive an invitation and can log in immediately. You can add more users later at [Company Users](/CompanyUsers). + After completing all steps, the wizard marks your setup as complete. The Dashboard will then show the "Get the most out of your shop" progress widget to guide you through your first live workflow steps. - After completing all steps, the wizard marks your setup as complete and removes the setup banner from the Dashboard. + ## GUIDED ACTIVATION — FIRST WORKFLOW + + After the Setup Wizard completes, Company Admins are guided through their first real workflow via a banner and the Daily Board. The guided activation flow: + + 1. Choose a starting path: Quote First (create a quote → approve it → convert to job) or Job First (create a job directly). + 2. The system creates a sample customer ("Sample Customer") to use during the walkthrough. You can use this record or swap it for a real customer. + 3. After the job is created, you are taken to the **Daily Board** ("This is your shop in real time") showing every active job by stage. The new job is highlighted with a glow border. + 4. Drag the highlighted job to its next stage. The board updates in real time and shows a confirmation: "Nice — your workflow just updated." + 5. The next prompt is to create an invoice when the work is done. + + The guided activation banner appears only for Company Admins and only until the first workflow is complete. It can be dismissed at any time. --- @@ -1192,7 +1189,7 @@ public static class HelpKnowledgeBase ## COMMON WORKFLOWS **New company first-time setup:** - Run Setup Wizard (Dashboard → Start Setup Wizard) → Add powder inventory items → Mark inventory category as "Is Coating" → Add customers → Build first quote + Run Setup Wizard (5 steps: Company Info → QB Migration → Pricing → Named Ovens → Notifications) → Complete guided activation first workflow on the Daily Board → Use the Dashboard progress widget to finish remaining setup steps (invite team, customize workflow labels, set payment terms) → Add powder inventory items → Add customers → Build first real quote **Standard job flow (most common):** Quote (Draft → Sent → Approved) → Convert to Job → Job progresses through statuses → Create Invoice → Record Payment → Mark Delivered diff --git a/src/PowderCoating.Web/Helpers/ToastHelper.cs b/src/PowderCoating.Web/Helpers/ToastHelper.cs index 9d479a3..22ff16b 100644 --- a/src/PowderCoating.Web/Helpers/ToastHelper.cs +++ b/src/PowderCoating.Web/Helpers/ToastHelper.cs @@ -31,6 +31,9 @@ namespace PowderCoating.Web.Helpers /// TempData key read by the layout to render a yellow warning toast. private const string WarningKey = "Warning"; + /// TempData key read by the layout to render a yellow warning toast that does not auto-dismiss. + private const string WarningPermanentKey = "WarningPermanent"; + /// TempData key read by the layout to render a blue info toast. private const string InfoKey = "Info"; @@ -67,6 +70,15 @@ namespace PowderCoating.Web.Helpers tempData[WarningKey] = message; } + /// + /// Stores a warning (yellow) toast that requires manual dismissal (no auto-timeout). + /// Use for critical warnings the user must not miss, such as email delivery failures. + /// + public static void WarningPermanent(this ITempDataDictionary tempData, string message) + { + tempData[WarningPermanentKey] = message; + } + /// /// Stores an informational (blue) toast message in TempData for display on /// the next page render after a redirect. @@ -123,6 +135,15 @@ namespace PowderCoating.Web.Helpers controller.TempData.Warning(message); } + /// + /// Stores a permanent warning (yellow, no auto-dismiss) in the controller's TempData. + /// Use for failures the user must not miss — email delivery errors, PDF generation failures. + /// + public static void ToastWarningPermanent(this Controller controller, string message) + { + controller.TempData.WarningPermanent(message); + } + /// /// Stores an informational (blue) toast in the controller's TempData. /// Convenience wrapper around . @@ -165,7 +186,7 @@ namespace PowderCoating.Web.Helpers : $"{channel} notification was skipped."); break; case NotificationStatus.Failed: - controller.ToastWarning(!string.IsNullOrEmpty(log.ErrorMessage) + controller.ToastWarningPermanent(!string.IsNullOrEmpty(log.ErrorMessage) ? $"{channel} delivery failed: {log.ErrorMessage}" : $"{channel} notification failed."); break; diff --git a/src/PowderCoating.Web/Program.cs b/src/PowderCoating.Web/Program.cs index 19c74fa..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; @@ -199,6 +200,8 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -207,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(); @@ -833,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"); @@ -892,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/src/PowderCoating.Web/ViewModels/Dashboard/ShopProgressWidgetViewModel.cs b/src/PowderCoating.Web/ViewModels/Dashboard/ShopProgressWidgetViewModel.cs new file mode 100644 index 0000000..58b25cc --- /dev/null +++ b/src/PowderCoating.Web/ViewModels/Dashboard/ShopProgressWidgetViewModel.cs @@ -0,0 +1,43 @@ +namespace PowderCoating.Web.ViewModels.Dashboard; + +public class ShopProgressWidgetViewModel +{ + public List Items { get; set; } = new(); + public int TotalItems => Items.Count; + public int CompletedCount => Items.Count(i => i.Done); + public bool AllDone => CompletedCount == TotalItems; + public int ProgressPercent => TotalItems == 0 ? 0 : CompletedCount * 100 / TotalItems; + + public string SubtitleText => CompletedCount switch + { + 0 => "You're set up and ready to go — these steps will help you run a tighter shop.", + 1 => "You're off to a great start — here's how to run your shop even smoother.", + 2 => "Good momentum — keep going.", + 3 => "Halfway there — great progress so far.", + 4 => "Almost there — just a couple more steps.", + 5 => "One step left — you're this close.", + _ => "You're all set." + }; + + public string BadgeText => CompletedCount switch + { + 0 => $"0 of {TotalItems} complete", + 1 => $"1 of {TotalItems} — you're on your way", + 2 => $"2 of {TotalItems} — good momentum", + 3 => $"3 of {TotalItems} — halfway there", + 4 => $"4 of {TotalItems} — almost there", + 5 => $"5 of {TotalItems} — one step left", + _ => $"{CompletedCount} of {TotalItems} complete" + }; +} + +public class ShopProgressItem +{ + public bool Done { get; set; } + public string Label { get; set; } = string.Empty; + public string SubLabel { get; set; } = string.Empty; + public string DoneSubLabel { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + public string CtaText { get; set; } = string.Empty; + public string CtaUrl { get; set; } = string.Empty; +} diff --git a/src/PowderCoating.Web/ViewModels/GuidedActivation/GuidedActivationViewModels.cs b/src/PowderCoating.Web/ViewModels/GuidedActivation/GuidedActivationViewModels.cs new file mode 100644 index 0000000..1a35a5b --- /dev/null +++ b/src/PowderCoating.Web/ViewModels/GuidedActivation/GuidedActivationViewModels.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace PowderCoating.Web.ViewModels.GuidedActivation; + +public class GuidedActivationSelectionViewModel +{ + [Required] + public string? OnboardingPath { get; set; } +} + +public class GuidedActivationBannerViewModel +{ + public bool Show { get; set; } + public bool IsDismissed { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string ActionText { get; set; } = "Start first workflow"; +} + +public class GuidedActivationCalloutViewModel +{ + public bool Show { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + /// Optional action prompt rendered below the message (e.g. "Move this job to the next stage…"). + public string? InstructionText { get; set; } + public string? ActionText { get; set; } + public string? ActionController { get; set; } + public string? ActionName { get; set; } + public object? ActionRouteValues { get; set; } + public string? SecondaryActionText { get; set; } + public string? SecondaryActionController { get; set; } + public string? SecondaryActionName { get; set; } + public object? SecondaryActionRouteValues { get; set; } +} diff --git a/src/PowderCoating.Web/Views/Dashboard/Index.cshtml b/src/PowderCoating.Web/Views/Dashboard/Index.cshtml index 1112155..705f615 100644 --- a/src/PowderCoating.Web/Views/Dashboard/Index.cshtml +++ b/src/PowderCoating.Web/Views/Dashboard/Index.cshtml @@ -1,12 +1,15 @@ @model PowderCoating.Application.DTOs.Dashboard.DashboardViewModel @using Microsoft.AspNetCore.Html @using PowderCoating.Application.DTOs.Health +@using PowderCoating.Web.ViewModels.Dashboard @{ ViewData["Title"] = "Dashboard"; var today = DateTime.Today; var currentMonth = DateTime.Now.ToString("MMMM yyyy"); var configHealth = ViewBag.ConfigHealth as CompanyConfigHealth; + var guidedActivationBanner = ViewBag.GuidedActivationBanner as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationBannerViewModel; + var shopProgressWidget = ViewBag.ShopProgressWidget as ShopProgressWidgetViewModel; } @@ -56,6 +59,34 @@ +@if (guidedActivationBanner?.Show == true) +{ +
+
+
+
+
+
+
@guidedActivationBanner.Title
+
@guidedActivationBanner.Message
+
+ +
+
+
+
+
+} + +@if (shopProgressWidget != null) +{ + @await Html.PartialAsync("_ShopProgressWidget", shopProgressWidget) +} + @* Config health alert — only shown when there are setup gaps *@ @if (configHealth != null && !configHealth.IsHealthy) { @@ -777,6 +808,7 @@ @section Scripts { + } diff --git a/src/PowderCoating.Web/Views/EmailBroadcast/Preview.cshtml b/src/PowderCoating.Web/Views/EmailBroadcast/Preview.cshtml new file mode 100644 index 0000000..f844f99 --- /dev/null +++ b/src/PowderCoating.Web/Views/EmailBroadcast/Preview.cshtml @@ -0,0 +1,135 @@ +@using PowderCoating.Web.Controllers +@model AdminEmailPreviewModel +@{ + ViewData["Title"] = "Preview Admin Email"; +} + +
+
+
+

Admin Email Wizard

+

Step 3 of 3: preview one merged sample, then send sequentially.

+
+
+ @Model.EligibleCount ready to send + @if (Model.SkippedCount > 0) + { + @Model.SkippedCount missing email + } +
+
+ +
+
+
+
Sample Preview
+
+
Recipient Sample
+
+
@Model.SamplePreview.RecipientName
+
@Model.SamplePreview.RecipientEmail
+
@Model.SamplePreview.CompanyName
+
+ +
Rendered Subject
+
@Model.SamplePreview.RenderedSubject
+ +
Rendered HTML Body
+
+ @Html.Raw(Model.SamplePreview.RenderedHtmlBody) +
+
+
+
+ +
+
+
Delivery Summary
+
+
+ The system will process each selected company one at a time. + The sample shown on the left uses the first available recipient after token replacement. +
+
+
+ +
+
Selected Companies
+
+ + + + + + + + + + + @foreach (var row in Model.SelectedCompanies) + { + + + + + + + } + +
CompanyRecipientCompany AdminReady
+
@row.CompanyName
+
#@row.CompanyId
+
+
@row.RecipientName
+
@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)
+
+
@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)
+ @if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail)) + { +
@row.CompanyAdminEmail
+ } +
+ @if (row.CanSend) + { + Ready + } + else + { + @row.SkipReason + } +
+
+
+
+
+ +
+ @Html.AntiForgeryToken() + + + @foreach (var companyId in Model.CompanyIds ?? Array.Empty()) + { + + } + +
+ + + @if (Model.EligibleCount > 0) + { + + } + else + { + + } +
+ +
diff --git a/src/PowderCoating.Web/Views/EmailBroadcast/SelectCompanies.cshtml b/src/PowderCoating.Web/Views/EmailBroadcast/SelectCompanies.cshtml new file mode 100644 index 0000000..e942eb6 --- /dev/null +++ b/src/PowderCoating.Web/Views/EmailBroadcast/SelectCompanies.cshtml @@ -0,0 +1,170 @@ +@using PowderCoating.Web.Controllers +@model AdminEmailSelectionModel +@{ + ViewData["Title"] = "Choose Companies"; +} + +
+
+
+

Admin Email Wizard

+

Step 2 of 3: choose which companies should receive this message.

+
+
@Model.AvailableCompanies.Count company records
+
+ +
+
+
+
+
Subject
+
@Model.Subject
+
+
+
Message Summary
+
Rich-text message prepared. Merge tokens will render on the preview step.
+
+
+
+
+ +
+ @Html.AntiForgeryToken() + + + +
+
+
+
+ +
+
+ + + 0 selected +
+
+ + + +
+ + + + + + + + + + + + + @foreach (var company in Model.AvailableCompanies) + { + + + + + + + + + } + +
CompanyPrimary ContactEmailCompany AdminStatus
+ + +
@company.CompanyName
+
#@company.CompanyId
+
@(string.IsNullOrWhiteSpace(company.PrimaryContactName) ? "—" : company.PrimaryContactName) + @if (string.IsNullOrWhiteSpace(company.PrimaryContactEmail)) + { + Missing + } + else + { + @company.PrimaryContactEmail + } + +
@(string.IsNullOrWhiteSpace(company.CompanyAdminName) ? "—" : company.CompanyAdminName)
+ @if (!string.IsNullOrWhiteSpace(company.CompanyAdminEmail)) + { +
@company.CompanyAdminEmail
+ } +
+ @if (company.IsActive) + { + Active + } + else + { + Inactive + } +
+
+ +
+ + +
+
+
+
+
+ +@section Scripts { + + +} diff --git a/src/PowderCoating.Web/Views/GuidedActivation/Select.cshtml b/src/PowderCoating.Web/Views/GuidedActivation/Select.cshtml new file mode 100644 index 0000000..90d2280 --- /dev/null +++ b/src/PowderCoating.Web/Views/GuidedActivation/Select.cshtml @@ -0,0 +1,113 @@ +@model PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationSelectionViewModel +@using PowderCoating.Shared.Constants + +@{ + ViewData["Title"] = "Start Your First Workflow"; +} + +@section Styles { + +} + +
+
+
+
Guided Activation
+

Your shop is set up. Let's run your first workflow.

+

+ Choose how jobs usually start for your shop and we'll guide you through it with real quotes, jobs, and invoices. +

+
+ +
+
+ @Html.AntiForgeryToken() +
+ +

How do jobs usually start for your shop?

+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ +
+ @Html.AntiForgeryToken() + +
+
+
+
+ +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} +} diff --git a/src/PowderCoating.Web/Views/Help/GettingStarted.cshtml b/src/PowderCoating.Web/Views/Help/GettingStarted.cshtml index 0bc5b24..40902f0 100644 --- a/src/PowderCoating.Web/Views/Help/GettingStarted.cshtml +++ b/src/PowderCoating.Web/Views/Help/GettingStarted.cshtml @@ -247,8 +247,9 @@
New to the system? Use the Setup Wizard to - configure your company, operating costs, named ovens, and initial inventory in a guided - 10-step walkthrough. The wizard is the fastest way to get your shop configured and ready. + configure your company profile, operating costs, named ovens, and notifications in a guided + 5-step walkthrough — takes about 5–10 minutes. After the wizard, the Dashboard will show + a progress checklist to guide you through your first live workflow.

@@ -327,6 +328,57 @@ +

+

+ After the Wizard — Your First Workflow +

+

+ Once the Setup Wizard is complete, two things appear on your Dashboard to guide you through your + first live workflow: +

+ +
Guided Activation
+

+ A banner prompts you to run a short first-workflow walkthrough. Choose a starting path — either + Quote First (create a quote, get it approved, convert it to a job) or + Job First (create a job directly). The system creates a sample customer + record for you to use during the walkthrough. +

+

+ After the job is created you are taken to the Daily Board — your shop in real + time. Every active job appears on the board by stage. The new job is highlighted so you can + find it easily. Drag it to its next stage to see how your workflow updates live. Once you have + moved the job, the board prompts you to create the invoice when the work is done. +

+ +
Progress Widget
+

+ Below the guided activation banner you will see a "Get the most out of your shop" + widget. It tracks six steps that unlock the full day-to-day workflow: +

+
    +
  1. Create your first job or quote
  2. +
  3. Move a job through your workflow
  4. +
  5. Send your first invoice
  6. +
  7. Bring your crew in (invite team members)
  8. +
  9. Customize your workflow labels (job stages, priorities, prep services)
  10. +
  11. Set how you get paid (payment terms and quote defaults)
  12. +
+

+ Each incomplete step shows a description and a button that takes you directly to the right place. + The next recommended step is highlighted. The widget disappears once all six steps are done. + You can collapse it using the chevron button — the collapsed state is saved in your browser. +

+ + +
+ diff --git a/src/PowderCoating.Web/Views/Help/Jobs.cshtml b/src/PowderCoating.Web/Views/Help/Jobs.cshtml index c7b28b5..e604772 100644 --- a/src/PowderCoating.Web/Views/Help/Jobs.cshtml +++ b/src/PowderCoating.Web/Views/Help/Jobs.cshtml @@ -77,6 +77,11 @@ (waiting for customer approval, missing materials, etc.) and Cancelled to formally close a job that will not be completed.

+

+ On the Jobs list, click any status badge to open a quick-change modal. The modal includes a + Notify customer via email toggle. If the customer has email notifications turned off, + that toggle is automatically disabled and a warning note is shown — no email will be sent regardless. +

diff --git a/src/PowderCoating.Web/Views/Help/Quotes.cshtml b/src/PowderCoating.Web/Views/Help/Quotes.cshtml index 5408b88..3f69008 100644 --- a/src/PowderCoating.Web/Views/Help/Quotes.cshtml +++ b/src/PowderCoating.Web/Views/Help/Quotes.cshtml @@ -40,6 +40,28 @@

Creating a Quote

+ +

Quick Quote vs Full Quote

+

+ The quote form offers two modes, selectable via the Quick Quote / Full Quote toggle at the + top of the page. Your selection is remembered automatically for next time. +

+
    +
  • Quick Quote — shows only the essentials: customer picker (or walk-in info) and + the item wizard. Dates, notes, tags, oven settings, discounts, and photos are hidden. Use this for + fast phone or counter estimates where you just need a price.
  • +
  • Full Quote — shows the complete form with all fields. Use this for formal + quotes where you want to capture notes, set an expiry date, apply a discount, or add photos.
  • +
+ +

To create a new quote:

  1. Go to Operations › Quotes and click New Quote.
  2. @@ -251,6 +273,16 @@
  3. Click Send Quote. The status changes from Draft to Sent.
  4. If email notifications are configured for your company, the customer will automatically receive an email with the quote details.
+

You can also manually mark a quote as Approved or Rejected when you hear back from the customer verbally or by phone, without going through a formal email send. diff --git a/src/PowderCoating.Web/Views/Invoices/Create.cshtml b/src/PowderCoating.Web/Views/Invoices/Create.cshtml index 96c008d..fed1ccf 100644 --- a/src/PowderCoating.Web/Views/Invoices/Create.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Create.cshtml @@ -37,6 +37,7 @@
@Html.AntiForgeryToken() + @if (!ViewData.ModelState.IsValid) { @@ -47,6 +48,14 @@ } + @if (ViewBag.GuidedActivation != null) + { +

+
Optional next step: Create the invoice
+
This uses the real invoice flow. Review the line items, then save when you want to close the loop with billing.
+
+ } + diff --git a/src/PowderCoating.Web/Views/Invoices/Details.cshtml b/src/PowderCoating.Web/Views/Invoices/Details.cshtml index 740ab26..35c9366 100644 --- a/src/PowderCoating.Web/Views/Invoices/Details.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Details.cshtml @@ -21,6 +21,7 @@ && (Model.PaymentLinkExpiresAt == null || Model.PaymentLinkExpiresAt <= DateTime.UtcNow); var onlinePaymentsEnabled = ViewBag.OnlinePaymentsEnabled == true; var showOnlinePaymentCard = !isDraft && !isVoided && Model.BalanceDue > 0 && onlinePaymentsEnabled; + var guidedActivationCallout = ViewBag.GuidedActivationCallout as PowderCoating.Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel; }
@@ -69,6 +70,23 @@
} + @if (guidedActivationCallout?.Show == true) + { +
+
+
+
@guidedActivationCallout.Title
+
@guidedActivationCallout.Message
+
+ +
+
+ } +
diff --git a/src/PowderCoating.Web/Views/Jobs/Board.cshtml b/src/PowderCoating.Web/Views/Jobs/Board.cshtml index b185535..e1d54da 100644 --- a/src/PowderCoating.Web/Views/Jobs/Board.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/Board.cshtml @@ -1,10 +1,17 @@ +@using PowderCoating.Shared.Constants @using PowderCoating.Web.Controllers +@using PowderCoating.Web.ViewModels.GuidedActivation @model List @{ ViewData["Title"] = "Jobs Board"; bool showTerminal = ViewBag.ShowTerminal == true; int totalTerminal = (int)(ViewBag.TotalTerminal ?? 0); - + var guidedActivationCallout = ViewBag.GuidedActivationCallout as GuidedActivationCalloutViewModel; + string? guidedActivation = ViewBag.GuidedActivation as string; + int? highlightJobId = ViewBag.GuidedActivationHighlightJobId is int highlightedId ? highlightedId : null; + var highlightedCard = highlightJobId.HasValue + ? Model.SelectMany(c => c.Jobs).FirstOrDefault(j => j.Id == highlightJobId.Value) + : null; } @section Styles { @@ -108,7 +115,19 @@ } .board-card:hover { background: var(--pcl-paper-2); color: var(--pcl-ink); } .board-card.board-card-hot { box-shadow: inset 2px 0 0 var(--pcl-bad); } + .board-card.board-card-guided { + border-color: var(--pcl-cool); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--pcl-cool) 24%, transparent); + background: color-mix(in srgb, var(--pcl-cool) 8%, var(--pcl-card)); + } .board-card.dragging { opacity: .5; cursor: grabbing; } + .board-guided-badge { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pcl-cool); + } /* Card content */ .card-job-number { font-family: var(--font-mono); font-weight: 500; font-size: .8rem; color: var(--pcl-ink); } @@ -171,6 +190,40 @@ }
+ @if (guidedActivationCallout?.Show == true) + { +
+
+
+
@guidedActivationCallout.Title
+
@guidedActivationCallout.Message
+ @if (!string.IsNullOrWhiteSpace(guidedActivationCallout.InstructionText)) + { +
+ @guidedActivationCallout.InstructionText +
+ } +
+
+ @if (!string.IsNullOrWhiteSpace(guidedActivationCallout.ActionText)) + { + + @guidedActivationCallout.ActionText + + } + @if (!string.IsNullOrWhiteSpace(guidedActivationCallout.SecondaryActionText)) + { + + @guidedActivationCallout.SecondaryActionText + + } +
+
+
+ } + @* Toolbar *@ @{ var _totalOnFloor = Model.Sum(c => c.Jobs.Count); @@ -181,7 +234,11 @@ @* Left: view switch + live stats *@
@@ -195,7 +252,7 @@ @* Right: actions *@
- @@ -286,12 +343,16 @@ _ => "board-priority-secondary" };
@card.JobNumber + @if (highlightJobId == card.Id) + { + Guided Job + }
@card.CustomerName
@@ -347,6 +408,8 @@ + + +
@@ -75,7 +77,7 @@ // ═══════════════════════════════════════════════════════════════════════════ // OVENS // ═══════════════════════════════════════════════════════════════════════════ - var ovens = JSON.parse(document.getElementById('ovensJson').value || '[]'); + var ovens = JSON.parse(document.getElementById('ovensSeedJson').textContent || '[]'); function serializeOvens() { document.getElementById('ovensJson').value = JSON.stringify( @@ -212,7 +214,7 @@ // ═══════════════════════════════════════════════════════════════════════════ // BLAST SETUPS // ═══════════════════════════════════════════════════════════════════════════ - var blasts = JSON.parse(document.getElementById('blastSetupsJson').value || '[]'); + var blasts = JSON.parse(document.getElementById('blastSetupsSeedJson').textContent || '[]'); function serializeBlasts() { document.getElementById('blastSetupsJson').value = JSON.stringify( diff --git a/src/PowderCoating.Web/Views/SetupWizard/Step9.cshtml b/src/PowderCoating.Web/Views/SetupWizard/Step9.cshtml index 13f9e2f..bf63a14 100644 --- a/src/PowderCoating.Web/Views/SetupWizard/Step9.cshtml +++ b/src/PowderCoating.Web/Views/SetupWizard/Step9.cshtml @@ -3,7 +3,7 @@ @{ ViewData["Title"] = "Setup Wizard — Notifications"; var progress = ViewBag.Progress as WizardProgressDto ?? new WizardProgressDto(); - int step = ViewBag.Step as int? ?? 9; + int step = ViewBag.Step as int? ?? 5; } @section Styles { @await Html.PartialAsync("_WizardStyles") } diff --git a/src/PowderCoating.Web/Views/SetupWizard/_WizardProgress.cshtml b/src/PowderCoating.Web/Views/SetupWizard/_WizardProgress.cshtml index 3936ff1..a354cdd 100644 --- a/src/PowderCoating.Web/Views/SetupWizard/_WizardProgress.cshtml +++ b/src/PowderCoating.Web/Views/SetupWizard/_WizardProgress.cshtml @@ -4,16 +4,11 @@ @{ var steps = new[] { - (1, "Company Profile", "bi-building"), - (2, "QB Migration", "bi-arrow-left-right"), - (3, "Operating Costs", "bi-currency-dollar"), - (4, "Shop Ovens", "bi-fire"), - (5, "Doc Numbering", "bi-palette"), - (6, "Job Settings", "bi-diagram-3"), - (7, "Payment Terms", "bi-file-earmark-text"), - (8, "Pricing Tiers", "bi-percent"), - (9, "Notifications", "bi-bell"), - (10, "Team Members", "bi-people"), + (1, "Company Profile", "bi-building"), + (2, "QB Migration", "bi-arrow-left-right"), + (3, "Operating Costs", "bi-currency-dollar"), + (4, "Shop Ovens", "bi-fire"), + (5, "Notifications", "bi-bell"), }; int currentStep = ViewBag.Step as int? ?? 1; } diff --git a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml index 449d017..fc9a0e2 100644 --- a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml +++ b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml @@ -890,6 +890,10 @@ { } + @if (TempData["WarningPermanent"] != null) + { + + } @if (TempData["Info"] != null) { diff --git a/src/PowderCoating.Web/wwwroot/js/item-wizard.js b/src/PowderCoating.Web/wwwroot/js/item-wizard.js index 6765fd1..6bdb869 100644 --- a/src/PowderCoating.Web/wwwroot/js/item-wizard.js +++ b/src/PowderCoating.Web/wwwroot/js/item-wizard.js @@ -65,11 +65,23 @@ document.addEventListener('DOMContentLoaded', () => { // Restore items from server round-trip (validation failure re-render) const existingEl = document.getElementById('existingItemsData'); if (existingEl) { - quoteItems = JSON.parse(existingEl.textContent); - renderAllCards(); - writeHiddenFields(); - window.scrollTo({ top: 0, behavior: 'instant' }); - scheduleAutoPricing(); + try { + quoteItems = JSON.parse(existingEl.textContent); + renderAllCards(); + writeHiddenFields(); + window.scrollTo({ top: 0, behavior: 'instant' }); + scheduleAutoPricing(); + } catch (err) { + console.error('item-wizard: failed to restore items from server model:', err); + } + } + + // Guarantee hidden fields are always written on form submission, even if the wizard + // was never interacted with (e.g. validation round-trip with pre-existing items). + const hfc = document.getElementById('hiddenFieldsContainer'); + const ownerForm = hfc?.closest('form'); + if (ownerForm) { + ownerForm.addEventListener('submit', writeHiddenFields, { capture: true }); } // Close any open powder combobox dropdown when clicking outside it diff --git a/src/PowderCoating.Web/wwwroot/js/shop-progress-widget.js b/src/PowderCoating.Web/wwwroot/js/shop-progress-widget.js new file mode 100644 index 0000000..6c83542 --- /dev/null +++ b/src/PowderCoating.Web/wwwroot/js/shop-progress-widget.js @@ -0,0 +1,43 @@ +(function () { + var STORAGE_KEY = 'shopProgressCollapsed'; + var DISMISSED_KEY = 'shopProgressDismissed'; + var widget = document.getElementById('shopProgressWidget'); + if (!widget) return; + + // Hide permanently if user dismissed the completion state + try { + if (localStorage.getItem(DISMISSED_KEY) === '1') { + widget.style.display = 'none'; + return; + } + } catch (e) { } + + var body = document.getElementById('shopProgressBody'); + var toggle = document.getElementById('shopProgressToggle'); + var chevron = document.getElementById('shopProgressChevron'); + + function setCollapsed(collapsed) { + body.style.display = collapsed ? 'none' : ''; + chevron.className = collapsed ? 'bi bi-chevron-down' : 'bi bi-chevron-up'; + toggle.title = collapsed ? 'Expand' : 'Collapse'; + try { localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0'); } catch (e) { } + } + + // Restore collapse state on load + try { + if (localStorage.getItem(STORAGE_KEY) === '1') setCollapsed(true); + } catch (e) { } + + toggle.addEventListener('click', function () { + setCollapsed(body.style.display === 'none' ? false : true); + }); + + // Dismiss button — completion state only + var dismiss = document.getElementById('shopProgressDismiss'); + if (dismiss) { + dismiss.addEventListener('click', function () { + widget.style.display = 'none'; + try { localStorage.setItem(DISMISSED_KEY, '1'); } catch (e) { } + }); + } +}()); diff --git a/src/PowderCoating.Web/wwwroot/js/toast-notifications.js b/src/PowderCoating.Web/wwwroot/js/toast-notifications.js index 06087c6..bf85dae 100644 --- a/src/PowderCoating.Web/wwwroot/js/toast-notifications.js +++ b/src/PowderCoating.Web/wwwroot/js/toast-notifications.js @@ -118,6 +118,12 @@ function displayTempDataMessages() { showWarning(warningMsg.textContent.trim()); } + // Permanent warning — no auto-dismiss + const warningPerm = document.getElementById('tempdata-warning-permanent-message'); + if (warningPerm && warningPerm.textContent.trim()) { + toastr.warning(warningPerm.textContent.trim(), 'Warning', { timeOut: 0, extendedTimeOut: 0 }); + } + // Info message const infoMsg = document.getElementById('tempdata-info-message'); if (infoMsg && infoMsg.textContent.trim()) { diff --git a/tests/PowderCoating.UnitTests/AccountBalanceServiceTests.cs b/tests/PowderCoating.UnitTests/AccountBalanceServiceTests.cs new file mode 100644 index 0000000..3e0ae10 --- /dev/null +++ b/tests/PowderCoating.UnitTests/AccountBalanceServiceTests.cs @@ -0,0 +1,300 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Application.DTOs.Accounting; +using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Infrastructure.Repositories; +using PowderCoating.Infrastructure.Services; + +namespace PowderCoating.UnitTests; + +public class AccountBalanceServiceTests +{ + // ── DebitAsync ──────────────────────────────────────────────────────── + + [Fact] + public async Task DebitAsync_WhenAccountIdIsNull_LeavesBalanceUnchanged() + { + await using var context = CreateContext(); + var account = SeedAccount(context, 1, AccountSubType.Checking, 100m); + await context.SaveChangesAsync(); + var (service, _) = CreateService(context); + + await service.DebitAsync(null, 50m); + + Assert.Equal(100m, account.CurrentBalance); + } + + [Fact] + public async Task DebitAsync_WhenAmountIsZero_LeavesBalanceUnchanged() + { + await using var context = CreateContext(); + var account = SeedAccount(context, 1, AccountSubType.Checking, 100m); + await context.SaveChangesAsync(); + var (service, _) = CreateService(context); + + await service.DebitAsync(1, 0m); + + Assert.Equal(100m, account.CurrentBalance); + } + + [Fact] + public async Task DebitAsync_WhenAccountNotFound_DoesNotThrow() + { + await using var context = CreateContext(); + var (service, _) = CreateService(context); + + // Account 999 does not exist — service must silently no-op + await service.DebitAsync(999, 50m); + } + + [Theory] + [InlineData(AccountSubType.Checking, 100, 50, 150)] // Asset — debit-normal + [InlineData(AccountSubType.Savings, 200, 75, 275)] // Asset — debit-normal + [InlineData(AccountSubType.AccountsReceivable, 0, 100, 100)] // Asset — debit-normal + [InlineData(AccountSubType.CostOfGoodsSold, 50, 25, 75)] // COGS — debit-normal + [InlineData(AccountSubType.Advertising, 0, 100, 100)] // Expense (≥50) — debit-normal + [InlineData(AccountSubType.Payroll, 40, 60, 100)] // Expense (≥50) — debit-normal + public async Task DebitAsync_OnDebitNormalAccount_IncreasesBalance( + AccountSubType subType, decimal start, decimal amount, decimal expected) + { + await using var context = CreateContext(); + var account = SeedAccount(context, 1, subType, start); + await context.SaveChangesAsync(); + var (service, _) = CreateService(context); + + await service.DebitAsync(1, amount); + + Assert.Equal(expected, account.CurrentBalance); + } + + [Theory] + [InlineData(AccountSubType.AccountsPayable, 100, 50, 50)] // Liability — credit-normal + [InlineData(AccountSubType.Sales, 200, 75, 125)] // Revenue — credit-normal + [InlineData(AccountSubType.OwnersEquity, 500, 100, 400)] // Equity — credit-normal + [InlineData(AccountSubType.RetainedEarnings, 300, 50, 250)] // Equity — credit-normal + public async Task DebitAsync_OnCreditNormalAccount_DecreasesBalance( + AccountSubType subType, decimal start, decimal amount, decimal expected) + { + await using var context = CreateContext(); + var account = SeedAccount(context, 1, subType, start); + await context.SaveChangesAsync(); + var (service, _) = CreateService(context); + + await service.DebitAsync(1, amount); + + Assert.Equal(expected, account.CurrentBalance); + } + + // ── CreditAsync ─────────────────────────────────────────────────────── + + [Fact] + public async Task CreditAsync_WhenAccountIdIsNull_LeavesBalanceUnchanged() + { + await using var context = CreateContext(); + var account = SeedAccount(context, 1, AccountSubType.Checking, 100m); + await context.SaveChangesAsync(); + var (service, _) = CreateService(context); + + await service.CreditAsync(null, 50m); + + Assert.Equal(100m, account.CurrentBalance); + } + + [Fact] + public async Task CreditAsync_WhenAmountIsZero_LeavesBalanceUnchanged() + { + await using var context = CreateContext(); + var account = SeedAccount(context, 1, AccountSubType.Checking, 100m); + await context.SaveChangesAsync(); + var (service, _) = CreateService(context); + + await service.CreditAsync(1, 0m); + + Assert.Equal(100m, account.CurrentBalance); + } + + [Theory] + [InlineData(AccountSubType.Checking, 200, 50, 150)] // Asset — debit-normal → credit decreases + [InlineData(AccountSubType.CostOfGoodsSold, 100, 30, 70)] // COGS — debit-normal → credit decreases + [InlineData(AccountSubType.SuppliesMaterials, 80, 20, 60)] // Expense (≥50) — debit-normal → credit decreases + public async Task CreditAsync_OnDebitNormalAccount_DecreasesBalance( + AccountSubType subType, decimal start, decimal amount, decimal expected) + { + await using var context = CreateContext(); + var account = SeedAccount(context, 1, subType, start); + await context.SaveChangesAsync(); + var (service, _) = CreateService(context); + + await service.CreditAsync(1, amount); + + Assert.Equal(expected, account.CurrentBalance); + } + + [Theory] + [InlineData(AccountSubType.AccountsPayable, 100, 50, 150)] // Liability — credit-normal → credit increases + [InlineData(AccountSubType.Sales, 0, 200, 200)] // Revenue — credit-normal → credit increases + [InlineData(AccountSubType.RetainedEarnings, 300, 100, 400)] // Equity — credit-normal → credit increases + public async Task CreditAsync_OnCreditNormalAccount_IncreasesBalance( + AccountSubType subType, decimal start, decimal amount, decimal expected) + { + await using var context = CreateContext(); + var account = SeedAccount(context, 1, subType, start); + await context.SaveChangesAsync(); + var (service, _) = CreateService(context); + + await service.CreditAsync(1, amount); + + Assert.Equal(expected, account.CurrentBalance); + } + + // ── RecalculateAllAsync ─────────────────────────────────────────────── + + [Fact] + public async Task RecalculateAllAsync_UpdatesEachActiveAccountFromLedgerClosingBalance() + { + await using var context = CreateContext(); + SeedAccount(context, 1, AccountSubType.Checking, 0m, companyId: 5); + SeedAccount(context, 2, AccountSubType.Sales, 0m, companyId: 5); + await context.SaveChangesAsync(); + + var ledger = new Mock(); + ledger.Setup(l => l.GetAccountLedgerAsync(1, It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccountLedgerDto { ClosingBalance = 500m }); + ledger.Setup(l => l.GetAccountLedgerAsync(2, It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccountLedgerDto { ClosingBalance = 1200m }); + + var (service, _) = CreateService(context, ledger); + await service.RecalculateAllAsync(5); + + var accounts = await context.Accounts.IgnoreQueryFilters() + .Where(a => a.CompanyId == 5).ToListAsync(); + Assert.Equal(500m, accounts.Single(a => a.Id == 1).CurrentBalance); + Assert.Equal(1200m, accounts.Single(a => a.Id == 2).CurrentBalance); + } + + [Fact] + public async Task RecalculateAllAsync_WhenLedgerReturnsNull_SkipsAccountBalance() + { + await using var context = CreateContext(); + SeedAccount(context, 10, AccountSubType.Checking, 999m, companyId: 6); + await context.SaveChangesAsync(); + + var ledger = new Mock(); + ledger.Setup(l => l.GetAccountLedgerAsync(10, It.IsAny(), It.IsAny())) + .ReturnsAsync((AccountLedgerDto?)null); + + var (service, _) = CreateService(context, ledger); + await service.RecalculateAllAsync(6); + + var account = await context.Accounts.IgnoreQueryFilters().SingleAsync(a => a.Id == 10); + Assert.Equal(999m, account.CurrentBalance); + } + + [Fact] + public async Task RecalculateAllAsync_WhenOneAccountThrows_StillUpdatesRemainingAccounts() + { + await using var context = CreateContext(); + SeedAccount(context, 20, AccountSubType.Checking, 0m, companyId: 7); + SeedAccount(context, 21, AccountSubType.Sales, 0m, companyId: 7); + await context.SaveChangesAsync(); + + var ledger = new Mock(); + ledger.Setup(l => l.GetAccountLedgerAsync(20, It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("simulated ledger error")); + ledger.Setup(l => l.GetAccountLedgerAsync(21, It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccountLedgerDto { ClosingBalance = 750m }); + + var (service, _) = CreateService(context, ledger); + await service.RecalculateAllAsync(7); + + var account21 = await context.Accounts.IgnoreQueryFilters().SingleAsync(a => a.Id == 21); + Assert.Equal(750m, account21.CurrentBalance); + } + + [Fact] + public async Task RecalculateAllAsync_ExcludesInactiveAccounts() + { + await using var context = CreateContext(); + SeedAccount(context, 30, AccountSubType.Checking, 0m, companyId: 8, isActive: false); + await context.SaveChangesAsync(); + + var ledger = new Mock(); + var (service, _) = CreateService(context, ledger); + + await service.RecalculateAllAsync(8); + + ledger.Verify( + l => l.GetAccountLedgerAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static (AccountBalanceService service, UnitOfWork unitOfWork) CreateService( + ApplicationDbContext context, + Mock? ledger = null) + { + var unitOfWork = new UnitOfWork(context); + var service = new AccountBalanceService( + unitOfWork, + (ledger ?? new Mock()).Object, + Mock.Of>()); + return (service, unitOfWork); + } + + private static Account SeedAccount( + ApplicationDbContext context, + int id, + AccountSubType subType, + decimal balance, + int companyId = 1, + bool isActive = true) + { + var account = new Account + { + Id = id, + CompanyId = companyId, + AccountNumber = $"ACCT-{id}", + Name = $"Account {id}", + AccountSubType = subType, + CurrentBalance = balance, + IsActive = isActive + }; + context.Accounts.Add(account); + return account; + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + // Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the + // global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null, + // which would filter out every row. DefaultHttpContext.Session throws, so we mock + // HttpContext itself and stub Session.TryGetValue to return false (no impersonation). + var identity = new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test"); + var principal = new ClaimsPrincipal(identity); + + byte[]? noBytes = null; + var sessionMock = new Mock(); + sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); + + var httpContextMock = new Mock(); + httpContextMock.SetupGet(c => c.User).Returns(principal); + httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); + + var accessor = new Mock(); + accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + + return new ApplicationDbContext(options, accessor.Object, null!); + } +} diff --git a/tests/PowderCoating.UnitTests/DepositsControllerTests.cs b/tests/PowderCoating.UnitTests/DepositsControllerTests.cs new file mode 100644 index 0000000..a2931bd --- /dev/null +++ b/tests/PowderCoating.UnitTests/DepositsControllerTests.cs @@ -0,0 +1,354 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Infrastructure.Repositories; +using PowderCoating.Web.Controllers; + +namespace PowderCoating.UnitTests; + +public class DepositsControllerTests +{ + // ── Record — input validation (no user resolution needed) ───────────── + + [Fact] + public async Task Record_WhenAmountIsZero_ReturnsJsonError() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 0m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("greater than zero", doc.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task Record_WhenAmountIsNegative_ReturnsJsonError() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: -50m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + } + + [Fact] + public async Task Record_WhenNeitherJobNorQuoteProvided_ReturnsJsonError() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Record(jobId: null, quoteId: null, customerId: 1, + amount: 100m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("Job or Quote must be specified", doc.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task Record_WhenInvalidPaymentMethod_ReturnsJsonError() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 100m, paymentMethod: "BitcoinPizza", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("Invalid payment method", doc.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task Record_WhenUserNotFound_ReturnsUnauthorized() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: null); + + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 100m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + Assert.IsType(result); + } + + // ── Record — success paths ──────────────────────────────────────────── + + [Fact] + public async Task Record_WithJobId_CreatesDepositAndReturnsSuccessJson() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(companyId: 1)); + var receivedDate = new DateTime(2026, 4, 15); + + var result = await controller.Record(jobId: 42, quoteId: null, customerId: 7, + amount: 250m, paymentMethod: "Check", + receivedDate: receivedDate, reference: "REF-001", notes: "Half up front"); + + using var doc = ParseJson(result); + Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal(250m, doc.RootElement.GetProperty("amount").GetDecimal()); + Assert.Equal("Check", doc.RootElement.GetProperty("paymentMethod").GetString()); + Assert.Equal("04/15/2026", doc.RootElement.GetProperty("receivedDate").GetString()); + Assert.Equal("REF-001", doc.RootElement.GetProperty("reference").GetString()); + Assert.Equal("Half up front", doc.RootElement.GetProperty("notes").GetString()); + + var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(42, deposit.JobId); + Assert.Null(deposit.QuoteId); + Assert.Equal(7, deposit.CustomerId); + Assert.Equal(250m, deposit.Amount); + Assert.Equal(PaymentMethod.Check, deposit.PaymentMethod); + } + + [Fact] + public async Task Record_WithQuoteId_CreatesDepositLinkedToQuote() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(companyId: 1)); + + var result = await controller.Record(jobId: null, quoteId: 99, customerId: 5, + amount: 500m, paymentMethod: "CreditDebitCard", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal("Credit / Debit Card", doc.RootElement.GetProperty("paymentMethod").GetString()); + + var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(99, deposit.QuoteId); + Assert.Null(deposit.JobId); + } + + [Fact] + public async Task Record_GeneratesReceiptNumberInExpectedFormat() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(companyId: 1)); + + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 100m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + var receiptNumber = doc.RootElement.GetProperty("receiptNumber").GetString()!; + + // Format: DEP-YYMM-#### (e.g. DEP-2604-0001) + Assert.Matches(@"^DEP-\d{4}-\d{4}$", receiptNumber); + Assert.EndsWith("-0001", receiptNumber); + } + + [Fact] + public async Task Record_SecondDepositInSameMonth_IncrementsSequenceNumber() + { + await using var context = CreateContext(); + var user = CreateUser(companyId: 1); + var controller = CreateController(context, currentUser: user); + + // First deposit + await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 100m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + // Second deposit + var result = await controller.Record(jobId: 2, quoteId: null, customerId: 1, + amount: 200m, paymentMethod: "Cash", + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + var receiptNumber = doc.RootElement.GetProperty("receiptNumber").GetString()!; + Assert.EndsWith("-0002", receiptNumber); + } + + [Fact] + public async Task Record_PaymentMethodDisplayStrings_AreHumanReadable() + { + await using var context = CreateContext(); + var user = CreateUser(companyId: 1); + + var cases = new[] + { + ("Cash", "Cash"), + ("Check", "Check"), + ("CreditDebitCard", "Credit / Debit Card"), + ("BankTransferACH", "Bank Transfer / ACH"), + ("DigitalPayment", "Digital Payment"), + }; + + foreach (var (method, expected) in cases) + { + var controller = CreateController(context, currentUser: user); + var result = await controller.Record(jobId: 1, quoteId: null, customerId: 1, + amount: 10m, paymentMethod: method, + receivedDate: DateTime.UtcNow, reference: null, notes: null); + + using var doc = ParseJson(result); + if (doc.RootElement.GetProperty("success").GetBoolean()) + Assert.Equal(expected, doc.RootElement.GetProperty("paymentMethod").GetString()); + } + } + + // ── Delete ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Delete_WhenDepositNotFound_ReturnsJsonError() + { + await using var context = CreateContext(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Delete(id: 999, returnUrl: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("not found", doc.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task Delete_WhenDepositAlreadyAppliedToInvoice_ReturnsJsonError() + { + await using var context = CreateContext(); + context.Deposits.Add(new Deposit + { + Id = 1, + CompanyId = 1, + ReceiptNumber = "DEP-2604-0001", + CustomerId = 1, + JobId = 1, + Amount = 100m, + PaymentMethod = PaymentMethod.Cash, + ReceivedDate = DateTime.UtcNow, + AppliedToInvoiceId = 55 // already applied + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Delete(id: 1, returnUrl: null); + + using var doc = ParseJson(result); + Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("already been applied", doc.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task Delete_WhenUnappliedDeposit_SoftDeletesAndReturnsSuccess() + { + await using var context = CreateContext(); + context.Deposits.Add(new Deposit + { + Id = 2, + CompanyId = 1, + ReceiptNumber = "DEP-2604-0001", + CustomerId = 1, + JobId = 1, + Amount = 75m, + PaymentMethod = PaymentMethod.Cash, + ReceivedDate = DateTime.UtcNow, + AppliedToInvoiceId = null // not yet applied + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, currentUser: CreateUser(1)); + + var result = await controller.Delete(id: 2, returnUrl: null); + + using var doc = ParseJson(result); + Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); + + var deposit = await context.Deposits.IgnoreQueryFilters().SingleAsync(d => d.Id == 2); + Assert.True(deposit.IsDeleted); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static DepositsController CreateController( + ApplicationDbContext context, + ApplicationUser? currentUser) + { + var uow = new UnitOfWork(context); + var userManager = CreateUserManagerMock(); + userManager + .Setup(m => m.GetUserAsync(It.IsAny())) + .ReturnsAsync(currentUser); + + var controller = new DepositsController( + uow, + userManager.Object, + Mock.Of>()); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + + return controller; + } + + private static ApplicationUser CreateUser(int companyId) => new() + { + Id = "test-user", + CompanyId = companyId, + UserName = "test@example.com", + Email = "test@example.com", + FirstName = "Test", + LastName = "User" + }; + + private static Mock> CreateUserManagerMock() + { + var store = new Mock>(); + return new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + } + + private static JsonDocument ParseJson(IActionResult result) + { + var json = Assert.IsType(result); + var serialized = JsonSerializer.Serialize(json.Value); + return JsonDocument.Parse(serialized); + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + // Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the + // global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null, + // which would filter out every row. DefaultHttpContext.Session throws, so we mock + // HttpContext itself and stub Session.TryGetValue to return false (no impersonation). + var identity = new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test"); + var principal = new ClaimsPrincipal(identity); + + byte[]? noBytes = null; + var sessionMock = new Mock(); + sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); + + var httpContextMock = new Mock(); + httpContextMock.SetupGet(c => c.User).Returns(principal); + httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); + + var accessor = new Mock(); + accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + + return new ApplicationDbContext(options, accessor.Object, null!); + } +} diff --git a/tests/PowderCoating.UnitTests/EmailBroadcastControllerTests.cs b/tests/PowderCoating.UnitTests/EmailBroadcastControllerTests.cs new file mode 100644 index 0000000..3b3f06f --- /dev/null +++ b/tests/PowderCoating.UnitTests/EmailBroadcastControllerTests.cs @@ -0,0 +1,203 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Shared.Constants; +using PowderCoating.Web.Controllers; + +namespace PowderCoating.UnitTests; + +public class EmailBroadcastControllerTests +{ + [Fact] + public async Task Preview_RendersMergedSampleAndSanitizesHtml() + { + await using var context = CreateContext(); + context.Companies.Add(new Company + { + Id = 7, + CompanyId = 7, + CompanyName = "River City Powder", + PrimaryContactName = "Jamie Rivera", + PrimaryContactEmail = "jamie@example.com", + IsActive = true + }); + context.Users.Add(new ApplicationUser + { + Id = "admin-7", + CompanyId = 7, + CompanyRole = AppConstants.CompanyRoles.CompanyAdmin, + FirstName = "Alex", + LastName = "Admin", + Email = "alex@example.com", + UserName = "alex@example.com", + IsActive = true + }); + await context.SaveChangesAsync(); + + var controller = CreateController(context); + + var result = await controller.Preview(new AdminEmailSelectionModel + { + Subject = "Update for {{CompanyName}}", + BodyHtml = "

Hi {{FirstName}}, contact {{CompanyAdminName}} at {{CompanyAdminEmail}}.

", + CompanyIds = [7] + }); + + var view = Assert.IsType(result); + var model = Assert.IsType(view.Model); + + Assert.Equal("Update for River City Powder", model.SamplePreview.RenderedSubject); + Assert.Contains("Hi Jamie", model.SamplePreview.RenderedHtmlBody); + Assert.Contains("Alex Admin", model.SamplePreview.RenderedHtmlBody); + Assert.Contains("alex@example.com", model.SamplePreview.RenderedHtmlBody); + Assert.DoesNotContain("(); + emailService + .Setup(x => x.SendEmailAsync( + "morgan@example.com", + "Morgan Lee", + "Notice for Summit Coatings", + It.IsAny(), + It.IsAny(), + null, + null, + null, + "admin-notify@example.com", + "Powder Coating Logix Admin")) + .ReturnsAsync((true, (string?)null)); + + var platformSettings = new Mock(); + platformSettings + .Setup(x => x.GetAsync(PlatformSettingKeys.AdminNotificationEmail)) + .ReturnsAsync("admin-notify@example.com,backup@example.com"); + + var controller = CreateController(context, emailService, platformSettings); + + var result = await controller.Send(new AdminEmailSendRequest + { + Subject = "Notice for {{CompanyName}}", + BodyHtml = "

Hello {{FirstName}},

Thanks for using {{CompanyName}}.

", + CompanyIds = [11] + }); + + var redirect = Assert.IsType(result); + Assert.Equal("Index", redirect.ActionName); + + var log = await context.NotificationLogs.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(NotificationType.AdminEmail, log.NotificationType); + Assert.Equal(NotificationStatus.Sent, log.Status); + Assert.Equal("morgan@example.com", log.Recipient); + Assert.Equal("Notice for Summit Coatings", log.Subject); + Assert.Contains("Hello Morgan", log.Message); + emailService.VerifyAll(); + } + + [Fact] + public async Task Send_WhenPrimaryContactEmailMissing_WritesSkippedLogWithoutSending() + { + await using var context = CreateContext(); + context.Companies.Add(new Company + { + Id = 13, + CompanyId = 13, + CompanyName = "No Inbox Inc", + PrimaryContactName = "Taylor Noemail", + PrimaryContactEmail = string.Empty, + IsActive = true + }); + await context.SaveChangesAsync(); + + var emailService = new Mock(); + var controller = CreateController(context, emailService); + + var result = await controller.Send(new AdminEmailSendRequest + { + Subject = "Heads up for {{CompanyName}}", + BodyHtml = "

Hello {{FirstName}}

", + CompanyIds = [13] + }); + + var redirect = Assert.IsType(result); + Assert.Equal("Index", redirect.ActionName); + + var log = await context.NotificationLogs.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(NotificationType.AdminEmail, log.NotificationType); + Assert.Equal(NotificationStatus.Skipped, log.Status); + Assert.Equal(string.Empty, log.Recipient); + Assert.Equal("Company primary contact email is not configured.", log.ErrorMessage); + emailService.Verify( + x => x.SendEmailAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + private static EmailBroadcastController CreateController( + ApplicationDbContext context, + Mock? emailService = null, + Mock? platformSettings = null) + { + var controller = new EmailBroadcastController( + context, + (emailService ?? new Mock()).Object, + (platformSettings ?? CreatePlatformSettings()).Object, + Mock.Of>()); + + var httpContext = new DefaultHttpContext(); + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + controller.TempData = new TempDataDictionary(httpContext, Mock.Of()); + + return controller; + } + + private static Mock CreatePlatformSettings() + { + var settings = new Mock(); + settings.Setup(x => x.GetAsync(It.IsAny())).ReturnsAsync((string?)null); + return settings; + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new ApplicationDbContext(options); + } +} diff --git a/tests/PowderCoating.UnitTests/FileServiceTests.cs b/tests/PowderCoating.UnitTests/FileServiceTests.cs new file mode 100644 index 0000000..b7255d6 --- /dev/null +++ b/tests/PowderCoating.UnitTests/FileServiceTests.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Application.Services; + +namespace PowderCoating.UnitTests; + +public class FileServiceTests +{ + [Fact] + public async Task DeleteFileAsync_ReturnsError_WhenPathEscapesUploadsRoot() + { + using var harness = new FileServiceHarness(); + + var result = await harness.Service.DeleteFileAsync("uploads/../secrets.txt"); + + Assert.False(result.Success); + Assert.Equal("Invalid file path.", result.ErrorMessage); + } + + [Fact] + public async Task GetFileAsync_ReturnsError_WhenPathEscapesUploadsRoot() + { + using var harness = new FileServiceHarness(); + + var result = await harness.Service.GetFileAsync("uploads/../../appsettings.json"); + + Assert.False(result.Success); + Assert.Equal("Invalid file path.", result.ErrorMessage); + Assert.Empty(result.FileContent); + } + + [Fact] + public async Task SaveFileAsync_ReturnsError_WhenSubfolderEscapesUploadsRoot() + { + using var harness = new FileServiceHarness(); + + var result = await harness.Service.SaveFileAsync( + CreateFormFile("manual.pdf"), + "../outside", + new[] { ".pdf" }, + 1024 * 1024); + + Assert.False(result.Success); + Assert.Equal("Invalid upload subfolder.", result.ErrorMessage); + } + + [Fact] + public async Task GetFileAsync_ReturnsFile_WhenPathIsUnderUploadsRoot() + { + using var harness = new FileServiceHarness(); + var uploadsPath = Path.Combine(harness.WebRootPath, "uploads", "equipment-manuals"); + Directory.CreateDirectory(uploadsPath); + + var fullPath = Path.Combine(uploadsPath, "manual.pdf"); + await File.WriteAllBytesAsync(fullPath, [1, 2, 3]); + + var result = await harness.Service.GetFileAsync("uploads/equipment-manuals/manual.pdf"); + + Assert.True(result.Success); + Assert.Equal("application/pdf", result.ContentType); + Assert.Equal(new byte[] { 1, 2, 3 }, result.FileContent); + } + + private static IFormFile CreateFormFile(string fileName) + { + var bytes = new byte[] { 1, 2, 3, 4 }; + var stream = new MemoryStream(bytes); + return new FormFile(stream, 0, bytes.Length, "file", fileName); + } + + private sealed class FileServiceHarness : IDisposable + { + public FileServiceHarness() + { + WebRootPath = Path.Combine(Path.GetTempPath(), "powdercoating-fileservice-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(WebRootPath); + + var environment = Mock.Of(x => x.WebRootPath == WebRootPath); + Service = new FileService(environment, Mock.Of>()); + } + + public string WebRootPath { get; } + public FileService Service { get; } + + public void Dispose() + { + if (Directory.Exists(WebRootPath)) + { + Directory.Delete(WebRootPath, recursive: true); + } + } + } +} diff --git a/tests/PowderCoating.UnitTests/GiftCertificatesControllerTests.cs b/tests/PowderCoating.UnitTests/GiftCertificatesControllerTests.cs new file mode 100644 index 0000000..8b05d6f --- /dev/null +++ b/tests/PowderCoating.UnitTests/GiftCertificatesControllerTests.cs @@ -0,0 +1,307 @@ +using System.Security.Claims; +using AutoMapper; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Application.DTOs.GiftCertificate; +using PowderCoating.Application.Interfaces; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Infrastructure.Repositories; +using PowderCoating.Web.Controllers; + +namespace PowderCoating.UnitTests; + +public class GiftCertificatesControllerTests +{ + // ── Index — lazy expiration ─────────────────────────────────────────── + + [Fact] + public async Task Index_LazilySetsExpiredStatus_ForActiveCertsPastExpiryDate() + { + await using var context = CreateContext(); + context.GiftCertificates.Add(new GiftCertificate + { + Id = 1, CompanyId = 1, + CertificateCode = "GC-2603-0001", + OriginalAmount = 100m, RedeemedAmount = 0, + Status = GiftCertificateStatus.Active, + IssueDate = DateTime.UtcNow.AddMonths(-2), + ExpiryDate = DateTime.UtcNow.AddDays(-1) // past expiry + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Index(null, null); + + var view = Assert.IsType(result); + var dtos = Assert.IsAssignableFrom>(view.Model); + Assert.Single(dtos); + Assert.Equal(GiftCertificateStatus.Expired, dtos[0].Status); + + var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(GiftCertificateStatus.Expired, dbCert.Status); + } + + [Fact] + public async Task Index_DoesNotExpire_ActiveCertsWithFutureExpiryDate() + { + await using var context = CreateContext(); + context.GiftCertificates.Add(new GiftCertificate + { + Id = 1, CompanyId = 1, + CertificateCode = "GC-2604-0001", + OriginalAmount = 50m, RedeemedAmount = 0, + Status = GiftCertificateStatus.Active, + IssueDate = DateTime.UtcNow, + ExpiryDate = DateTime.UtcNow.AddDays(30) // future expiry + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + await controller.Index(null, null); + + var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(GiftCertificateStatus.Active, dbCert.Status); + } + + // ── Index — filtering ───────────────────────────────────────────────── + + [Fact] + public async Task Index_StatusFilter_ReturnsOnlyMatchingStatus() + { + await using var context = CreateContext(); + context.GiftCertificates.AddRange( + new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-2604-0001", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow }, + new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-2604-0002", OriginalAmount = 200m, Status = GiftCertificateStatus.Voided, IssueDate = DateTime.UtcNow }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Index(null, nameof(GiftCertificateStatus.Active)); + + var view = Assert.IsType(result); + var dtos = Assert.IsAssignableFrom>(view.Model); + var dto = Assert.Single(dtos); + Assert.Equal(GiftCertificateStatus.Active, dto.Status); + } + + [Fact] + public async Task Index_SearchTerm_FiltersByCertificateCode() + { + await using var context = CreateContext(); + context.GiftCertificates.AddRange( + new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-2604-0001", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow }, + new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-2604-0002", OriginalAmount = 100m, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Index("0001", null); + + var view = Assert.IsType(result); + var dtos = Assert.IsAssignableFrom>(view.Model); + var dto = Assert.Single(dtos); + Assert.Equal("GC-2604-0001", dto.CertificateCode); + } + + [Fact] + public async Task Index_ViewBag_TotalValueIncludesOnlyActiveAndPartiallyRedeemed() + { + await using var context = CreateContext(); + context.GiftCertificates.AddRange( + new GiftCertificate { Id = 1, CompanyId = 1, CertificateCode = "GC-A", OriginalAmount = 100m, RedeemedAmount = 0, Status = GiftCertificateStatus.Active, IssueDate = DateTime.UtcNow }, + new GiftCertificate { Id = 2, CompanyId = 1, CertificateCode = "GC-P", OriginalAmount = 80m, RedeemedAmount = 30m, Status = GiftCertificateStatus.PartiallyRedeemed, IssueDate = DateTime.UtcNow }, + new GiftCertificate { Id = 3, CompanyId = 1, CertificateCode = "GC-V", OriginalAmount = 200m, RedeemedAmount = 200m, Status = GiftCertificateStatus.FullyRedeemed, IssueDate = DateTime.UtcNow }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + await controller.Index(null, null); + + // Active: RemainingBalance=100, PartiallyRedeemed: 80-30=50, FullyRedeemed: excluded + Assert.Equal(150m, controller.ViewBag.TotalValue); + Assert.Equal(2, controller.ViewBag.TotalActive); + } + + // ── Create — success paths ──────────────────────────────────────────── + + [Fact] + public async Task Create_WithSoldReason_SetsPurchasePriceAndGeneratesCode() + { + await using var context = CreateContext(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Create(new CreateGiftCertificateDto + { + Amount = 100m, + IssuedReason = GiftCertificateIssuedReason.Sold, + PurchasePrice = 80m, + PurchasingCustomerId = null, + RecipientName = "Jane Doe" + }); + + Assert.IsType(result); + + var cert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(80m, cert.PurchasePrice); + Assert.Equal(GiftCertificateStatus.Active, cert.Status); + Assert.Matches(@"^GC-\d{4}-\d{4}$", cert.CertificateCode); + Assert.EndsWith("-0001", cert.CertificateCode); + } + + [Fact] + public async Task Create_WithNonSoldReason_NullsPurchasePriceAndPurchasingCustomerId() + { + await using var context = CreateContext(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Create(new CreateGiftCertificateDto + { + Amount = 50m, + IssuedReason = GiftCertificateIssuedReason.Promotional, + PurchasePrice = 50m, // Provided but should be nulled + PurchasingCustomerId = 7 // Provided but should be nulled + }); + + Assert.IsType(result); + + var cert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Null(cert.PurchasePrice); + Assert.Null(cert.PurchasingCustomerId); + } + + // ── Void ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Void_ActiveCert_ChangesStatusToVoidedAndRedirectsToIndex() + { + await using var context = CreateContext(); + context.GiftCertificates.Add(new GiftCertificate + { + Id = 1, CompanyId = 1, + CertificateCode = "GC-2604-0001", + OriginalAmount = 100m, RedeemedAmount = 0, + Status = GiftCertificateStatus.Active, + IssueDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Void(id: 1); + + var redirect = Assert.IsType(result); + Assert.Equal("Index", redirect.ActionName); + + var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(GiftCertificateStatus.Voided, dbCert.Status); + } + + [Fact] + public async Task Void_FullyRedeemedCert_BlocksWithTempDataErrorAndRedirectsToDetails() + { + await using var context = CreateContext(); + context.GiftCertificates.Add(new GiftCertificate + { + Id = 2, CompanyId = 1, + CertificateCode = "GC-2604-0002", + OriginalAmount = 100m, RedeemedAmount = 100m, + Status = GiftCertificateStatus.FullyRedeemed, + IssueDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Void(id: 2); + + var redirect = Assert.IsType(result); + Assert.Equal("Details", redirect.ActionName); + Assert.Contains("fully redeemed", controller.TempData["Error"]?.ToString()); + + var dbCert = await context.GiftCertificates.IgnoreQueryFilters().SingleAsync(); + Assert.Equal(GiftCertificateStatus.FullyRedeemed, dbCert.Status); // unchanged + } + + [Fact] + public async Task Void_NonExistentCert_ReturnsNotFound() + { + await using var context = CreateContext(); + var controller = CreateController(context, CreateUser(companyId: 1)); + + var result = await controller.Void(id: 999); + + Assert.IsType(result); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static GiftCertificatesController CreateController( + ApplicationDbContext context, + ApplicationUser? currentUser) + { + var uow = new UnitOfWork(context); + var userManager = CreateUserManagerMock(); + userManager + .Setup(m => m.GetUserAsync(It.IsAny())) + .ReturnsAsync(currentUser); + + var httpContext = new DefaultHttpContext(); + var controller = new GiftCertificatesController( + uow, + Mock.Of(), + Mock.Of>(), + userManager.Object, + Mock.Of()); + + controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + controller.TempData = new TempDataDictionary(httpContext, Mock.Of()); + return controller; + } + + private static ApplicationUser CreateUser(int companyId) => new() + { + Id = "test-user", + CompanyId = companyId, + UserName = "test@example.com", + Email = "test@example.com", + FirstName = "Test", + LastName = "User" + }; + + private static Mock> CreateUserManagerMock() + { + var store = new Mock>(); + return new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + // Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the + // global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null, + // which would filter out every row. DefaultHttpContext.Session throws, so we mock + // HttpContext itself and stub Session.TryGetValue to return false (no impersonation). + var identity = new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test"); + var principal = new ClaimsPrincipal(identity); + + byte[]? noBytes = null; + var sessionMock = new Mock(); + sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); + + var httpContextMock = new Mock(); + httpContextMock.SetupGet(c => c.User).Returns(principal); + httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); + + var accessor = new Mock(); + accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + + return new ApplicationDbContext(options, accessor.Object, null!); + } +} diff --git a/tests/PowderCoating.UnitTests/LedgerServiceTests.cs b/tests/PowderCoating.UnitTests/LedgerServiceTests.cs new file mode 100644 index 0000000..08bacd7 --- /dev/null +++ b/tests/PowderCoating.UnitTests/LedgerServiceTests.cs @@ -0,0 +1,616 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Infrastructure.Services; + +namespace PowderCoating.UnitTests; + +public class LedgerServiceTests +{ + private static readonly DateTime PeriodStart = new DateTime(2026, 4, 1); + private static readonly DateTime PeriodEnd = new DateTime(2026, 4, 30); + private static readonly DateTime InPeriod = new DateTime(2026, 4, 15); + + // ── Returns null for unknown account ───────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_WhenAccountDoesNotExist_ReturnsNull() + { + await using var context = CreateContext(); + var service = CreateService(context); + + var result = await service.GetAccountLedgerAsync(999, PeriodStart, PeriodEnd); + + Assert.Null(result); + } + + // ── Source 1: customer payment deposited (DEBIT) ───────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source1_CustomerPaymentDeposited_CreatesDebitEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Checking); + SeedInvoice(context, id: 99); + context.Payments.Add(new Payment + { + Id = 1, CompanyId = 1, + InvoiceId = 99, + DepositAccountId = 1, + Amount = 250m, + PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Check + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Customer Payment", entry.Source); + Assert.Equal(250m, entry.Debit); + Assert.Equal(0m, entry.Credit); + } + + // ── Source 2: expense paid FROM account (CREDIT) ────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source2_ExpensePaidFromAccount_CreatesCreditEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Checking); + context.Expenses.Add(new Expense + { + Id = 1, CompanyId = 1, + ExpenseNumber = "EXP-001", + PaymentAccountId = 1, // Checking account pays the expense + ExpenseAccountId = 999, // Different account — so Source 6 does not also fire + Amount = 80m, + Date = InPeriod, + PaymentMethod = PaymentMethod.Check + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Expense", entry.Source); + Assert.Equal(0m, entry.Debit); + Assert.Equal(80m, entry.Credit); + } + + // ── Source 3: bill payment made FROM account (CREDIT) ───────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source3_BillPaymentFromAccount_CreatesCreditEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Checking); + SeedBill(context, id: 99); // Include(bp => bp.Bill) requires the Bill to exist + context.BillPayments.Add(new BillPayment + { + Id = 1, CompanyId = 1, + PaymentNumber = "BP-001", + BillId = 99, + VendorId = 1, + BankAccountId = 1, // Checking account used to pay the bill + Amount = 150m, + PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Check + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Bill Payment", entry.Source); + Assert.Equal(0m, entry.Debit); + Assert.Equal(150m, entry.Credit); + } + + // ── Source 4: invoice revenue line items (CREDIT) ───────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source4_InvoiceLineItem_CreatesCreditEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Sales); + context.Invoices.Add(new Invoice + { + Id = 10, CompanyId = 1, + InvoiceNumber = "INV-001", + CustomerId = 1, + Status = InvoiceStatus.Sent, + InvoiceDate = InPeriod, + Total = 500m + }); + context.InvoiceItems.Add(new InvoiceItem + { + Id = 1, CompanyId = 1, + InvoiceId = 10, + RevenueAccountId = 1, + Description = "Powder Coating", + Quantity = 1, UnitPrice = 500m, TotalPrice = 500m + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Invoice", entry.Source); + Assert.Equal(0m, entry.Debit); + Assert.Equal(500m, entry.Credit); + } + + [Fact] + public async Task GetAccountLedgerAsync_Source4_DraftAndVoidedInvoicesExcluded() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Sales); + context.Invoices.AddRange( + new Invoice { Id = 10, CompanyId = 1, InvoiceNumber = "INV-DRAFT", CustomerId = 1, Status = InvoiceStatus.Draft, InvoiceDate = InPeriod }, + new Invoice { Id = 11, CompanyId = 1, InvoiceNumber = "INV-VOID", CustomerId = 1, Status = InvoiceStatus.Voided, InvoiceDate = InPeriod }); + context.InvoiceItems.AddRange( + new InvoiceItem { Id = 1, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "D", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m }, + new InvoiceItem { Id = 2, CompanyId = 1, InvoiceId = 11, RevenueAccountId = 1, Description = "V", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Empty(ledger!.Entries); + } + + // ── Source 5: sales tax collected (CREDIT) ──────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source5_SalesTaxCollected_CreatesCreditEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.OtherCurrentLiability); + context.Invoices.Add(new Invoice + { + Id = 10, CompanyId = 1, + InvoiceNumber = "INV-TAX", + CustomerId = 1, + Status = InvoiceStatus.Sent, + InvoiceDate = InPeriod, + TaxAmount = 42m, + SalesTaxAccountId = 1, + Total = 542m + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Sales Tax", entry.Source); + Assert.Equal(0m, entry.Debit); + Assert.Equal(42m, entry.Credit); + } + + // ── Source 6: expense categorized to account (DEBIT) ────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source6_ExpenseCategorizedToAccount_CreatesDebitEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Advertising); + context.Expenses.Add(new Expense + { + Id = 1, CompanyId = 1, + ExpenseNumber = "EXP-002", + ExpenseAccountId = 1, // Advertising account receives the expense + PaymentAccountId = 999, // Different account — so Source 2 does not also fire + Amount = 300m, + Date = InPeriod, + PaymentMethod = PaymentMethod.Check + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Expense", entry.Source); + Assert.Equal(300m, entry.Debit); + Assert.Equal(0m, entry.Credit); + } + + // ── Source 7: bill line items to expense account (DEBIT) ────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source7_BillLineItem_CreatesDebitEntry() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.SuppliesMaterials); + context.Bills.Add(new Bill + { + Id = 10, CompanyId = 1, + BillNumber = "BILL-001", + VendorId = 1, + APAccountId = 999, // AP account different from test account + Status = BillStatus.Open, + BillDate = InPeriod, + Total = 200m + }); + context.BillLineItems.Add(new BillLineItem + { + Id = 1, CompanyId = 1, + BillId = 10, + AccountId = 1, + Description = "Powder supplies", + Quantity = 1, UnitPrice = 200m, Amount = 200m + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal("Bill", entry.Source); + Assert.Equal(200m, entry.Debit); + Assert.Equal(0m, entry.Credit); + } + + [Fact] + public async Task GetAccountLedgerAsync_Source7_DraftAndVoidedBillsExcluded() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.SuppliesMaterials); + context.Bills.AddRange( + new Bill { Id = 10, CompanyId = 1, BillNumber = "B-DRAFT", VendorId = 1, APAccountId = 999, Status = BillStatus.Draft, BillDate = InPeriod, Total = 100m }, + new Bill { Id = 11, CompanyId = 1, BillNumber = "B-VOID", VendorId = 1, APAccountId = 999, Status = BillStatus.Voided, BillDate = InPeriod, Total = 100m }); + context.BillLineItems.AddRange( + new BillLineItem { Id = 1, CompanyId = 1, BillId = 10, AccountId = 1, Description = "D", Quantity = 1, UnitPrice = 100m, Amount = 100m }, + new BillLineItem { Id = 2, CompanyId = 1, BillId = 11, AccountId = 1, Description = "V", Quantity = 1, UnitPrice = 100m, Amount = 100m }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Empty(ledger!.Entries); + } + + // ── Source 8: Accounts Receivable ───────────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source8_AR_InvoicesDebitPaymentsCredit() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.AccountsReceivable); + SeedCustomer(context, id: 1); // Include(i => i.Customer) requires the Customer to exist + context.Invoices.Add(new Invoice + { + Id = 10, CompanyId = 1, + InvoiceNumber = "INV-AR", + CustomerId = 1, + Status = InvoiceStatus.Sent, + InvoiceDate = InPeriod, + Total = 400m + }); + context.Payments.Add(new Payment + { + Id = 1, CompanyId = 1, + InvoiceId = 10, + Amount = 100m, + PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Cash + // No DepositAccountId — so Source 1 does not also fire for this account + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Equal(2, ledger!.Entries.Count); + var invoiceEntry = ledger.Entries.Single(e => e.Source == "Invoice"); + var paymentEntry = ledger.Entries.Single(e => e.Source == "Invoice Payment"); + Assert.Equal(400m, invoiceEntry.Debit); + Assert.Equal(0m, invoiceEntry.Credit); + Assert.Equal(0m, paymentEntry.Debit); + Assert.Equal(100m, paymentEntry.Credit); + Assert.Equal(300m, ledger.ClosingBalance); // debit-normal: 400 - 100 + } + + // ── Source 9: Accounts Payable ──────────────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_Source9_AP_BillsCreditBillPaymentsDebit() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.AccountsPayable); + SeedVendor(context, id: 1); // Include(b => b.Vendor) requires the Vendor to exist + context.Bills.Add(new Bill + { + Id = 10, CompanyId = 1, + BillNumber = "BILL-AP", + VendorId = 1, + APAccountId = 1, // This is the AP account under test + Status = BillStatus.Open, + BillDate = InPeriod, + Total = 400m + }); + // Bill must be seeded because the WHERE for BillPayments navigates through Bill.APAccountId + context.BillPayments.Add(new BillPayment + { + Id = 1, CompanyId = 1, + PaymentNumber = "BP-AP", + BillId = 10, + VendorId = 1, + BankAccountId = 999, // Different account — so Source 3 does not also fire + Amount = 150m, + PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Check + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Equal(2, ledger!.Entries.Count); + var billEntry = ledger.Entries.Single(e => e.Source == "Bill"); + var paymentEntry = ledger.Entries.Single(e => e.Source == "Bill Payment"); + Assert.Equal(0m, billEntry.Debit); + Assert.Equal(400m, billEntry.Credit); + Assert.Equal(150m, paymentEntry.Debit); + Assert.Equal(0m, paymentEntry.Credit); + Assert.Equal(250m, ledger.ClosingBalance); // credit-normal: 400 - 150 + } + + // ── Date range filtering ────────────────────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_DateRangeFiltering_ExcludesEntriesOutsideRange() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Checking); + var dates = new[] + { + new DateTime(2026, 3, 15), // before period — excluded from entries, goes to priorBalance + new DateTime(2026, 4, 15), // in period — included + new DateTime(2026, 5, 15), // after period — excluded + }; + var i = 0; + foreach (var date in dates) + { + context.Expenses.Add(new Expense + { + Id = ++i, CompanyId = 1, + ExpenseNumber = $"EXP-{i:D3}", + PaymentAccountId = 1, + ExpenseAccountId = 999, + Amount = 100m, + Date = date, + PaymentMethod = PaymentMethod.Cash + }); + } + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + var entry = Assert.Single(ledger!.Entries); + Assert.Equal(InPeriod.Date, entry.Date.Date); + } + + // ── Running balance ─────────────────────────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_RunningBalance_DebitNormalAccount_CorrectlyComputed() + { + await using var context = CreateContext(); + // Opening balance of 100 on Checking (debit-normal); null date means always applied + SeedAccount(context, id: 1, AccountSubType.Checking, openingBalance: 100m, openingBalanceDate: null); + SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist + context.Payments.AddRange( + new Payment { Id = 1, CompanyId = 1, InvoiceId = 10, DepositAccountId = 1, Amount = 50m, PaymentDate = new DateTime(2026, 4, 10), PaymentMethod = PaymentMethod.Cash }, + new Payment { Id = 2, CompanyId = 1, InvoiceId = 10, DepositAccountId = 1, Amount = 75m, PaymentDate = new DateTime(2026, 4, 20), PaymentMethod = PaymentMethod.Cash }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Equal(100m, ledger!.OpeningBalance); + Assert.Equal(2, ledger.Entries.Count); + Assert.Equal(150m, ledger.Entries[0].RunningBalance); // 100 + 50 + Assert.Equal(225m, ledger.Entries[1].RunningBalance); // 150 + 75 + Assert.Equal(225m, ledger.ClosingBalance); + } + + [Fact] + public async Task GetAccountLedgerAsync_RunningBalance_CreditNormalAccount_CorrectlyComputed() + { + await using var context = CreateContext(); + // Opening balance of 200 on Sales (credit-normal); null date means always applied + SeedAccount(context, id: 1, AccountSubType.Sales, openingBalance: 200m, openingBalanceDate: null); + // Source 4: InvoiceItems must have the Invoice seeded because the WHERE navigates through Invoice + context.Invoices.Add(new Invoice + { + Id = 10, CompanyId = 1, + InvoiceNumber = "INV-001", + CustomerId = 1, + Status = InvoiceStatus.Sent, + InvoiceDate = InPeriod, + Total = 150m + }); + context.InvoiceItems.AddRange( + new InvoiceItem { Id = 1, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "A", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m }, + new InvoiceItem { Id = 2, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "B", Quantity = 1, UnitPrice = 50m, TotalPrice = 50m }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Equal(200m, ledger!.OpeningBalance); + Assert.Equal(2, ledger.Entries.Count); + // Credit-normal: runningBalance += Credit - Debit + // After entry 1 (Credit=100): 200 + 100 = 300 + // After entry 2 (Credit=50): 300 + 50 = 350 + Assert.Equal(350m, ledger.ClosingBalance); + } + + // ── Opening balance — future-dated excluded ─────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_FutureDatedOpeningBalance_ExcludedFromPriorBalance() + { + await using var context = CreateContext(); + // Opening balance dated 2027-01-01 — later than PeriodEnd (2026-04-30), so excluded + SeedAccount(context, id: 1, AccountSubType.Checking, + openingBalance: 500m, openingBalanceDate: new DateTime(2027, 1, 1)); + SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist + context.Payments.Add(new Payment + { + Id = 1, CompanyId = 1, + InvoiceId = 10, DepositAccountId = 1, + Amount = 100m, PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Cash + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + // OpeningBalance (prior balance) = 0, not 500 — future-dated opening balance excluded + Assert.Equal(0m, ledger!.OpeningBalance); + Assert.Equal(100m, ledger.ClosingBalance); // 0 + 100 + } + + // ── Period totals ───────────────────────────────────────────────────── + + [Fact] + public async Task GetAccountLedgerAsync_PeriodTotals_CorrectlySumDebitsAndCredits() + { + await using var context = CreateContext(); + SeedAccount(context, id: 1, AccountSubType.Checking); + SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist + // Source 1: payment deposited → DEBIT 200 + context.Payments.Add(new Payment + { + Id = 1, CompanyId = 1, + InvoiceId = 10, DepositAccountId = 1, + Amount = 200m, PaymentDate = InPeriod, + PaymentMethod = PaymentMethod.Cash + }); + // Source 2: expense paid from account → CREDIT 50 + context.Expenses.Add(new Expense + { + Id = 1, CompanyId = 1, + ExpenseNumber = "EXP-001", + PaymentAccountId = 1, // Checking pays + ExpenseAccountId = 999, // Expense account (different) — Source 6 does not also fire + Amount = 50m, + Date = InPeriod, + PaymentMethod = PaymentMethod.Cash + }); + await context.SaveChangesAsync(); + + var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd); + + Assert.Equal(200m, ledger!.PeriodDebits); + Assert.Equal(50m, ledger.PeriodCredits); + Assert.Equal(150m, ledger.ClosingBalance); // debit-normal: 200 - 50 + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static LedgerService CreateService(ApplicationDbContext context) + => new LedgerService(context, Mock.Of>()); + + private static Account SeedAccount( + ApplicationDbContext context, + int id, + AccountSubType subType, + int companyId = 1, + decimal openingBalance = 0m, + DateTime? openingBalanceDate = null) + { + var account = new Account + { + Id = id, + CompanyId = companyId, + AccountNumber = $"ACCT-{id:D4}", + Name = $"Account {id}", + AccountSubType = subType, + OpeningBalance = openingBalance, + OpeningBalanceDate = openingBalanceDate, + IsActive = true + }; + context.Accounts.Add(account); + return account; + } + + private static Invoice SeedInvoice(ApplicationDbContext context, int id, int companyId = 1) + { + var invoice = new Invoice + { + Id = id, + CompanyId = companyId, + InvoiceNumber = $"INV-{id:D4}", + CustomerId = 1, + Status = InvoiceStatus.Sent, + InvoiceDate = InPeriod, + Total = 0m + }; + context.Invoices.Add(invoice); + return invoice; + } + + private static void SeedVendor(ApplicationDbContext context, int id, int companyId = 1) + { + context.Vendors.Add(new Vendor + { + Id = id, + CompanyId = companyId, + CompanyName = $"Vendor {id}" + }); + } + + private static void SeedCustomer(ApplicationDbContext context, int id, int companyId = 1) + { + context.Customers.Add(new Customer + { + Id = id, + CompanyId = companyId, + CompanyName = $"Customer {id}" + }); + } + + private static void SeedBill(ApplicationDbContext context, int id, int companyId = 1) + { + context.Bills.Add(new Bill + { + Id = id, + CompanyId = companyId, + BillNumber = $"BILL-{id:D4}", + VendorId = 999, + APAccountId = 999, + Status = BillStatus.Open, + BillDate = InPeriod, + Total = 0m + }); + } + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + // Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the + // global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null, + // which would filter out every row. DefaultHttpContext.Session throws, so we mock + // HttpContext itself and stub Session.TryGetValue to return false (no impersonation). + var identity = new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test"); + var principal = new ClaimsPrincipal(identity); + + byte[]? noBytes = null; + var sessionMock = new Mock(); + sessionMock.Setup(s => s.TryGetValue(It.IsAny(), out noBytes)).Returns(false); + + var httpContextMock = new Mock(); + httpContextMock.SetupGet(c => c.User).Returns(principal); + httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object); + + var accessor = new Mock(); + accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + + return new ApplicationDbContext(options, accessor.Object, null!); + } +} diff --git a/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs b/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs index 8419a16..55a1cec 100644 --- a/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs +++ b/tests/PowderCoating.UnitTests/PricingCalculationServiceTests.cs @@ -4,6 +4,7 @@ using PowderCoating.Application.DTOs.Quote; using PowderCoating.Application.Services; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; +using PowderCoating.Core.Interfaces.Repositories; using Xunit; namespace PowderCoating.UnitTests; @@ -548,7 +549,7 @@ public class PricingCalculationServiceTests costs.MonthlyRent = 0m; costs.MonthlyUtilities = 0m; - var customerRepo = new Mock>(); + var customerRepo = new Mock(); customerRepo .Setup(x => x.FindAsync(It.IsAny>>(), false, It.IsAny>[]>())) .ReturnsAsync(new[] @@ -625,7 +626,7 @@ public class PricingCalculationServiceTests .Setup(x => x.GetByIdAsync(It.IsAny(), false, It.IsAny>[]>())) .ReturnsAsync(catalogItem); - var customerRepo = new Mock>(); + var customerRepo = new Mock(); customerRepo .Setup(x => x.FindAsync(It.IsAny>>(), false, It.IsAny>[]>())) .ReturnsAsync(Array.Empty()); diff --git a/tests/PowderCoating.UnitTests/QuoteApprovalControllerTests.cs b/tests/PowderCoating.UnitTests/QuoteApprovalControllerTests.cs index e26b790..c78e418 100644 --- a/tests/PowderCoating.UnitTests/QuoteApprovalControllerTests.cs +++ b/tests/PowderCoating.UnitTests/QuoteApprovalControllerTests.cs @@ -10,6 +10,7 @@ using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; +using PowderCoating.Infrastructure.Repositories; using PowderCoating.Web.Controllers; using PowderCoating.Web.Hubs; using PowderCoating.Web.ViewModels; @@ -319,7 +320,7 @@ public class QuoteApprovalControllerTests hubContext.SetupGet(x => x.Clients).Returns(hubClients.Object); var controller = new QuoteApprovalController( - context, + new UnitOfWork(context), notifications.Object, inApp.Object, Mock.Of(), diff --git a/tests/PowderCoating.UnitTests/WebhooksControllerTests.cs b/tests/PowderCoating.UnitTests/WebhooksControllerTests.cs new file mode 100644 index 0000000..58989df --- /dev/null +++ b/tests/PowderCoating.UnitTests/WebhooksControllerTests.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Web.Controllers; + +namespace PowderCoating.UnitTests; + +public class WebhooksControllerTests +{ + [Fact] + public async Task TwilioSms_ReturnsForbid_WhenAuthTokenMissingOutsideDevelopment() + { + await using var dbContext = CreateDbContext(); + var controller = CreateController(dbContext, Array.Empty>(), "Production"); + controller.ControllerContext = new ControllerContext + { + HttpContext = CreateHttpContext() + }; + + var result = await controller.TwilioSms(new TwilioSmsPayload { From = "+15551234567", Body = "STOP" }); + + Assert.IsType(result); + } + + [Fact] + public async Task TwilioSms_AllowsMissingAuthToken_InDevelopment() + { + await using var dbContext = CreateDbContext(); + var controller = CreateController(dbContext, Array.Empty>(), "Development"); + controller.ControllerContext = new ControllerContext + { + HttpContext = CreateHttpContext() + }; + + var result = await controller.TwilioSms(new TwilioSmsPayload()); + + var content = Assert.IsType(result); + Assert.Equal("application/xml", content.ContentType); + Assert.Equal("", content.Content); + } + + private static WebhooksController CreateController( + ApplicationDbContext dbContext, + IEnumerable> configValues, + string environmentName) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configValues) + .Build(); + + var environment = Mock.Of(x => x.EnvironmentName == environmentName); + + return new WebhooksController( + dbContext, + configuration, + environment, + Mock.Of>()); + } + + private static DefaultHttpContext CreateHttpContext() + { + var context = new DefaultHttpContext(); + context.Request.Method = HttpMethods.Post; + context.Request.Scheme = "https"; + context.Request.Host = new HostString("example.com"); + context.Request.Path = "/Webhooks/TwilioSms"; + context.Features.Set(new FormFeature(new FormCollection(new Dictionary + { + ["From"] = "+15551234567", + ["Body"] = "STOP" + }))); + return context; + } + + private static ApplicationDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new ApplicationDbContext(options); + } +}