Merge branch 'dev'

This commit is contained in:
2026-04-29 08:11:37 -04:00
141 changed files with 25128 additions and 3836 deletions
+45
View File
@@ -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<T>`)
```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
+346
View File
@@ -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<T>` 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<T>` cannot express live in typed repositories or
> read services — both accessible through `IUnitOfWork`.
---
## Target Architecture
```
Controllers (Presentation Layer)
├── IUnitOfWork.EntityName → IRepository<T> 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<T> 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<T>` 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.
File diff suppressed because it is too large Load Diff
@@ -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; }
@@ -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",
@@ -12,7 +12,7 @@ public class WizardProgressDto
public bool Completed { get; set; }
public List<int> DoneSteps { get; set; } = new();
public List<int> 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);
@@ -0,0 +1,29 @@
namespace PowderCoating.Application.Interfaces;
/// <summary>Pre-aggregated AI call counts for one company across four time windows.</summary>
public record AiCompanyUsage(int CompanyId, int Today, int Last7Days, int Last30Days, int AllTime);
/// <summary>Count of AI calls for a specific feature within a company (last 30 days).</summary>
public record AiFeatureStat(int CompanyId, string Feature, int Count);
/// <summary>Bundled result returned by <see cref="IAiUsageReportService.GetReportDataAsync"/>.</summary>
public record AiUsageReportData(
List<AiCompanyUsage> UsageByCompany,
List<AiFeatureStat> FeatureStats,
Dictionary<int, int> PhotoCountsByCompany);
/// <summary>
/// Read-only service for the platform AI usage analytics report. Queries <c>AiUsageLogs</c>
/// and <c>QuotePhotos</c> (cross-tenant, non-BaseEntity) via <c>ApplicationDbContext</c>
/// directly so that <see cref="AiUsageReportController"/> does not need a direct DB context reference.
/// Implemented in Infrastructure; used as Tier-3 aggregate report service.
/// </summary>
public interface IAiUsageReportService
{
/// <summary>
/// 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.
/// </summary>
Task<AiUsageReportData> GetReportDataAsync();
}
@@ -0,0 +1,23 @@
using PowderCoating.Application.DTOs.Accounting;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// 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.
/// </summary>
public interface IFinancialReportService
{
/// <summary>Returns a Profit &amp; Loss report for the given company and date range.</summary>
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf);
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
/// <summary>Returns a Sales &amp; Income report for the given company and date range.</summary>
Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to);
}
@@ -0,0 +1,48 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// 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.
/// </summary>
public record JobCycleTimeReport(List<JobCycleTimeRow> Rows, int Months);
public record JobCycleTimeRow(string StatusName, double AvgDaysInStatus, int JobCount);
public record PowderUsageReport(List<PowderUsageRow> Rows, int Months);
public record PowderUsageRow(string ColorName, string VendorName, decimal TotalLbs, decimal TotalCost);
/// <summary>
/// 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.
/// </summary>
public interface IOperationalReportService
{
/// <summary>Returns average time jobs spend in each status over the given lookback period.</summary>
Task<JobCycleTimeReport> GetJobCycleTimeAsync(int companyId, int months);
/// <summary>Returns powder usage (lbs and cost) broken down by color and vendor.</summary>
Task<PowderUsageReport> GetPowderUsageAsync(int companyId, int months);
/// <summary>
/// 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.
/// </summary>
Task<List<Bill>> GetActiveBillsAsync();
/// <summary>
/// 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.
/// </summary>
Task<List<Expense>> GetAllExpensesAsync();
/// <summary>
/// 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.
/// </summary>
Task<List<JobStatusHistory>> GetAllJobStatusHistoryAsync();
}
@@ -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<InvoiceItem, InvoiceItemDto>()
@@ -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<JobItem, JobItemDto>()
@@ -14,6 +14,7 @@ namespace PowderCoating.Application.Services;
/// </summary>
public class FileService : IFileService
{
private const string UploadsRootFolder = "uploads";
private readonly IWebHostEnvironment _environment;
private readonly ILogger<FileService> _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 <see cref="Path.GetFileName"/> 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 <c>wwwroot</c>) suitable for storing in the database.
/// slashes. The target subfolder is resolved and confined under <c>wwwroot/uploads/</c> before
/// any file system access occurs. Returns a relative path (from <c>wwwroot</c>) suitable for
/// storing in the database.
/// </summary>
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
/// <summary>
/// Deletes a file given its relative path from <c>wwwroot</c>.
/// 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
/// <see cref="Path.Combine"/> rather than string concatenation to prevent directory traversal.
/// existence before calling. The relative path is normalized and must remain under
/// <c>wwwroot/uploads/</c>; paths outside that root are rejected.
/// </summary>
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
/// <summary>
/// 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 <c>wwwroot</c> (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 <c>wwwroot/uploads/</c> path but
/// are otherwise not directly exposed through the static-files middleware.
/// </summary>
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<byte>(), 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<byte>(), string.Empty, pathError);
}
if (!File.Exists(fullPath))
{
@@ -175,7 +188,7 @@ public class FileService : IFileService
}
/// <summary>
/// Checks whether a file exists at the given <c>wwwroot</c>-relative path without reading it.
/// Checks whether a file exists at the given <c>wwwroot/uploads/</c>-relative path without reading it.
/// Used by views and controllers to conditionally show download links only when the file is present.
/// </summary>
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);
}
}
@@ -86,6 +86,22 @@ public class CompanyPreferences : BaseEntity
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
public string? QbMigrationStateJson { get; set; }
// Guided activation / first-workflow onboarding
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
public string? OnboardingPath { get; set; }
/// <summary>True once the company completes its first guided real workflow.</summary>
public bool FirstWorkflowCompleted { get; set; } = false;
/// <summary>UTC timestamp of when the first guided workflow was completed.</summary>
public DateTime? FirstWorkflowCompletedAt { get; set; }
/// <summary>UTC timestamp of the company's first quote creation.</summary>
public DateTime? FirstQuoteCreatedAt { get; set; }
/// <summary>UTC timestamp of the company's first job creation.</summary>
public DateTime? FirstJobCreatedAt { get; set; }
/// <summary>UTC timestamp of the company's first invoice creation.</summary>
public DateTime? FirstInvoiceCreatedAt { get; set; }
/// <summary>UTC timestamp of when the company dismissed guided activation without completing it.</summary>
public DateTime? GuidedActivationDismissedAt { get; set; }
// Navigation
public virtual Company Company { get; set; } = null!;
}
@@ -17,5 +17,6 @@ public enum NotificationType
SubscriptionExpiryReminder = 10,
SubscriptionExpired = 11,
SmsInboundStop = 12,
SmsInboundHelp = 13
SmsInboundHelp = 13,
AdminEmail = 14
}
@@ -0,0 +1,29 @@
using System.Linq.Expressions;
namespace PowderCoating.Core.Interfaces;
/// <summary>
/// Lightweight repository interface for platform-level entities that do not inherit
/// <see cref="PowderCoating.Core.Entities.BaseEntity"/> (e.g. Announcement, BannedIp,
/// DashboardTip, ReleaseNote). These entities have no CompanyId, no IsDeleted, and no
/// soft-delete semantics — so the full IRepository&lt;T&gt; contract (SoftDeleteAsync,
/// ignoreQueryFilters) does not apply.
/// </summary>
/// <typeparam name="T">Any EF-mapped class (does not need to inherit BaseEntity).</typeparam>
public interface IPlainRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);
Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate);
Task<bool> AnyAsync(Expression<Func<T, bool>> predicate);
Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null);
Task<T> AddAsync(T entity);
Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> entities);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
Task DeleteAsync(int id);
}
@@ -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<AiItemPrediction> AiItemPredictions { get; }
// Powder Insights
IRepository<PowderUsageLog> PowderUsageLogs { get; }
IPowderUsageLogRepository PowderUsageLogs { get; }
// Core entities
IRepository<Customer> Customers { get; }
IRepository<Job> Jobs { get; }
// Core entities — typed repositories for complex domains
ICustomerRepository Customers { get; }
IJobRepository Jobs { get; }
IRepository<JobDailyPriority> JobDailyPriorities { get; }
IRepository<JobItem> JobItems { get; }
IRepository<JobItemCoat> JobItemCoats { get; }
IJobItemCoatRepository JobItemCoats { get; }
IRepository<JobItemPrepService> JobItemPrepServices { get; }
IRepository<JobChangeHistory> JobChangeHistories { get; }
IRepository<Quote> Quotes { get; }
IRepository<JobPrepService> JobPrepServices { get; }
IQuoteRepository Quotes { get; }
IRepository<QuotePhoto> QuotePhotos { get; }
IRepository<QuoteItem> QuoteItems { get; }
IRepository<QuoteItemCoat> QuoteItemCoats { get; }
IRepository<QuoteItemPrepService> QuoteItemPrepServices { get; }
IRepository<QuoteChangeHistory> QuoteChangeHistories { get; }
IRepository<InventoryItem> InventoryItems { get; }
IRepository<InventoryTransaction> InventoryTransactions { get; }
IInventoryTransactionRepository InventoryTransactions { get; }
IRepository<Equipment> Equipment { get; }
IRepository<OvenCost> OvenCosts { get; }
IRepository<CompanyBlastSetup> BlastSetups { get; }
IRepository<MaintenanceRecord> MaintenanceRecords { get; }
IRepository<Vendor> Vendors { get; }
IRepository<JobPhoto> JobPhotos { get; }
IJobPhotoRepository JobPhotos { get; }
IRepository<JobNote> JobNotes { get; }
IRepository<CustomerNote> CustomerNotes { get; }
IRepository<JobStatusHistory> JobStatusHistory { get; }
@@ -69,38 +72,46 @@ public interface IUnitOfWork : IDisposable
IRepository<OvenBatch> OvenBatches { get; }
IRepository<OvenBatchItem> OvenBatchItems { get; }
// Invoices, Payments & Deposits
IRepository<Invoice> Invoices { get; }
// Invoices, Payments & Deposits — typed repository for complex include chains
IInvoiceRepository Invoices { get; }
IRepository<InvoiceItem> InvoiceItems { get; }
IRepository<Payment> Payments { get; }
IRepository<Deposit> Deposits { get; }
// Purchase Orders
IRepository<PurchaseOrder> PurchaseOrders { get; }
// Purchase Orders — typed repository for paged/filtered list and detail load
IPurchaseOrderRepository PurchaseOrders { get; }
IRepository<PurchaseOrderItem> PurchaseOrderItems { get; }
// Expense Tracking / Accounts Payable
// Expense Tracking / Accounts Payable — typed repository for Bills
IRepository<Account> Accounts { get; }
IRepository<Bill> Bills { get; }
IBillRepository Bills { get; }
IRepository<BillLineItem> BillLineItems { get; }
IRepository<BillPayment> BillPayments { get; }
IRepository<Expense> Expenses { get; }
// Notifications
IRepository<NotificationLog> NotificationLogs { get; }
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
INotificationLogRepository NotificationLogs { get; }
IRepository<NotificationTemplate> NotificationTemplates { get; }
// Subscription
IRepository<SubscriptionPlanConfig> SubscriptionPlanConfigs { get; }
// Job Templates
IRepository<JobTemplate> JobTemplates { get; }
IJobTemplateRepository JobTemplates { get; }
IRepository<JobTemplateItem> JobTemplateItems { get; }
IRepository<JobTemplateItemCoat> JobTemplateItemCoats { get; }
IRepository<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; }
// Platform content (SuperAdmin-managed, no tenant filter, no soft delete)
IPlainRepository<Announcement> Announcements { get; }
IPlainRepository<BannedIp> BannedIps { get; }
IPlainRepository<DashboardTip> DashboardTips { get; }
IRepository<InAppNotification> InAppNotifications { get; }
IPlainRepository<ReleaseNote> ReleaseNotes { get; }
// Bug Reports
IRepository<BugReport> BugReports { get; }
IRepository<BugReportAttachment> BugReportAttachments { get; }
// Contact Us
IRepository<ContactSubmission> ContactSubmissions { get; }
@@ -0,0 +1,49 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Bill"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IBillRepository : IRepository<Bill>
{
/// <summary>
/// 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.
/// </summary>
Task<Bill?> LoadForViewAsync(int id);
/// <summary>
/// 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.
/// </summary>
Task<Bill?> LoadForEditAsync(int id);
/// <summary>
/// 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.
/// </summary>
Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount);
/// <summary>
/// Returns the last bill number with the given prefix (including soft-deleted records) for
/// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted.
/// </summary>
Task<string?> GetLastBillNumberAsync(string prefix);
/// <summary>
/// Returns the last payment number with the given prefix (including soft-deleted records)
/// for sequential payment reference generation.
/// </summary>
Task<string?> GetLastPaymentNumberAsync(string prefix);
/// <summary>
/// Returns all non-deleted bills whose <c>BillDate</c> falls within [<paramref name="start"/>,
/// <paramref name="end"/>], with Vendor, LineItems → Account, and Payments loaded.
/// Used by the accounting data export to produce QuickBooks IIF / CSV files.
/// </summary>
Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end);
}
@@ -0,0 +1,22 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Customer"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface ICustomerRepository : IRepository<Customer>
{
/// <summary>
/// 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.
/// </summary>
Task<Customer?> LoadForDetailsAsync(int id);
/// <summary>
/// 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.
/// </summary>
Task<Customer?> FindByEmailAsync(string email);
}
@@ -0,0 +1,22 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="InventoryTransaction"/> that adds a dynamic-filter
/// query for the Inventory Ledger view on top of the generic CRUD interface.
/// </summary>
public interface IInventoryTransactionRepository : IRepository<InventoryTransaction>
{
/// <summary>
/// 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.
/// </summary>
Task<List<InventoryTransaction>> GetForLedgerAsync(
int? itemId,
DateTime? from,
DateTime? to,
InventoryTransactionType? type);
}
@@ -0,0 +1,52 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Invoice"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IInvoiceRepository : IRepository<Invoice>
{
/// <summary>
/// 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.
/// </summary>
Task<Invoice?> LoadForViewAsync(int id);
/// <summary>
/// Returns the invoice linked to a job, or null if none exists. Pass
/// <paramref name="includeDeleted"/> = true to also surface soft-deleted invoices (used by
/// the 1:1 uniqueness guard that prevents duplicate invoices for the same job).
/// </summary>
Task<Invoice?> GetForJobAsync(int jobId, bool includeDeleted = false);
/// <summary>
/// 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.
/// </summary>
Task<Invoice?> GetByPaymentTokenAsync(string token);
/// <summary>
/// Returns the last invoice number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted invoices) for sequential number generation.
/// </summary>
Task<string?> GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix);
/// <summary>
/// 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.
/// </summary>
Task<List<Invoice>> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to);
/// <summary>
/// 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.
/// </summary>
Task<List<Refund>> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to);
}
@@ -0,0 +1,40 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="JobItemCoat"/> that adds ThenInclude-based load methods the
/// generic <see cref="IRepository{T}"/> cannot express. Used by DashboardController for powder
/// order marking, powder receipt, and custom powder inventory creation.
/// </summary>
public interface IJobItemCoatRepository : IRepository<JobItemCoat>
{
/// <summary>
/// Loads a coat with the full vendor + job chain needed by the <c>MarkPowderOrdered</c> action:
/// <c>JobItem → Job → Customer</c>, <c>InventoryItem → PrimaryVendor</c>, and direct
/// <c>Vendor</c>. Returns <see langword="null"/> if not found.
/// </summary>
Task<JobItemCoat?> LoadForOrderMarkingAsync(int id);
/// <summary>
/// Loads a coat with only <c>InventoryItem</c> included — used by <c>ReceivePowder</c> for
/// the initial stock update. Returns <see langword="null"/> if not found.
/// </summary>
Task<JobItemCoat?> LoadWithInventoryAsync(int id);
/// <summary>
/// Loads a coat with <c>JobItem → Job</c> included — used by <c>ReceivePowder</c> to verify
/// company ownership when the initial load did not include the job chain. EF Core identity-map
/// fixup propagates <c>JobItem</c> back to any previously tracked instance of the same coat.
/// Returns <see langword="null"/> if not found.
/// </summary>
Task<JobItemCoat?> LoadWithJobChainAsync(int id);
/// <summary>
/// Returns all non-deleted coats that have no linked inventory item and belong to
/// <paramref name="companyId"/>, excluding <paramref name="excludeCoatId"/>. Used by
/// <c>AddCustomPowderToInventory</c> to link sibling coats to the newly created item.
/// Entities are tracked so that <c>InventoryItemId</c> mutations are saved via UnitOfWork.
/// </summary>
Task<List<JobItemCoat>> GetCandidateCoatsForLinkingAsync(int excludeCoatId, int companyId);
}
@@ -0,0 +1,28 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="JobPhoto"/> 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 <see cref="IRepository{T}"/>
/// cannot express.
/// </summary>
public interface IJobPhotoRepository : IRepository<JobPhoto>
{
/// <summary>
/// Returns all non-deleted, tagged job photos whose <c>Tags</c> field contains
/// <paramref name="colorName"/> or <paramref name="itemName"/> (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.
/// </summary>
Task<List<JobPhoto>> GetTaggedPhotosAsync(string? colorName, string? itemName);
/// <summary>
/// Returns all non-deleted job photos from jobs that use a specific inventory item
/// in any coat, matched via <c>JobItemCoat.InventoryItemId</c>. Loads
/// Job → Customer and Job → JobItems → Coats navigations. Used by the
/// Photos by Powder panel on the inventory item detail page.
/// </summary>
Task<List<JobPhoto>> GetPhotosByPowderItemAsync(int inventoryItemId);
}
@@ -0,0 +1,88 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Job"/> that extends the generic CRUD interface with
/// domain-specific queries that require multi-level include chains the generic
/// <see cref="IRepository{T}"/> cannot express.
/// </summary>
public interface IJobRepository : IRepository<Job>
{
/// <summary>
/// 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.
/// </summary>
Task<List<Job>> GetBoardJobsAsync();
/// <summary>
/// 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.
/// </summary>
Task<Job?> LoadForDetailsAsync(int id);
/// <summary>
/// Loads a single job with the include chain required by the Edit form: same as
/// <see cref="LoadForDetailsAsync"/> but without the read-only audit navigations, and
/// with tracking enabled so changes can be saved.
/// </summary>
Task<Job?> LoadForEditAsync(int id);
/// <summary>
/// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump).
/// Includes only JobStatus. Returns null if not found or soft-deleted.
/// </summary>
Task<Job?> LoadForStatusChangeAsync(int id);
/// <summary>
/// Returns the change history for a job, ordered newest-first, with ChangedBy navigation
/// loaded. Used by the Details view changelog tab.
/// </summary>
Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId);
/// <summary>
/// Returns the last job number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted jobs) for sequential number generation.
/// </summary>
Task<string?> GetLastJobNumberByPrefixAsync(int companyId, string prefix);
/// <summary>
/// Looks up a job created from <paramref name="quoteId"/> 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.
/// </summary>
Task<Job?> GetOrphanedConversionJobAsync(int quoteId, int companyId);
/// <summary>
/// Loads all jobs scheduled for <paramref name="date"/> that are not in a terminal status,
/// with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats) navigations.
/// Optionally filtered to a single worker when <paramref name="userId"/> is supplied.
/// Used by the ShopDisplay (TV board) action.
/// </summary>
Task<List<Job>> GetScheduledJobsForDateAsync(DateTime date, string? userId = null);
/// <summary>
/// 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 <paramref name="workerId"/> is supplied.
/// </summary>
Task<List<Job>> GetActiveJobsForMobileAsync(int companyId, string? workerId = null);
/// <summary>
/// Loads a single job with the navigations required by the costing breakdown endpoint:
/// OvenCost, Invoice, JobItems (with Coats), and TimeEntries (with Worker).
/// Scoped to <paramref name="companyId"/> as an extra safety check.
/// Returns null if not found.
/// </summary>
Task<Job?> LoadForCostingAsync(int jobId, int companyId);
/// <summary>
/// Loads a single job with JobItems → Coats and JobItems → PrepServices for deep-copying
/// into a new <see cref="JobTemplate"/> via <c>SaveJobAsTemplate</c>.
/// Returns null if not found or soft-deleted.
/// </summary>
Task<Job?> LoadForTemplateSnapshotAsync(int jobId);
}
@@ -0,0 +1,25 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="JobTemplate"/> that extends the generic CRUD interface with
/// domain-specific queries requiring multi-level include chains the generic
/// <see cref="IRepository{T}"/> cannot express.
/// </summary>
public interface IJobTemplateRepository : IRepository<JobTemplate>
{
/// <summary>
/// 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.
/// </summary>
Task<JobTemplate?> LoadForDetailsAsync(int id);
/// <summary>
/// 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.
/// </summary>
Task<List<JobTemplate>> GetAllActiveWithFullIncludesAsync();
}
@@ -0,0 +1,46 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="NotificationLog"/> 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.
/// </summary>
public interface INotificationLogRepository : IRepository<NotificationLog>
{
/// <summary>Returns the most recent notification log entry for the given invoice, or null.</summary>
Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId);
/// <summary>Returns all notification log entries for the given invoice, newest-first.</summary>
Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId);
/// <summary>Returns the most recent notification log entry for the given quote, or null.</summary>
Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId);
/// <summary>Returns all notification log entries for the given quote, newest-first.</summary>
Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId);
/// <summary>Returns the most recent notification log entry for the given job, or null.</summary>
Task<NotificationLog?> GetLatestForJobAsync(int jobId);
/// <summary>Returns all notification log entries for the given job, newest-first.</summary>
Task<List<NotificationLog>> GetAllForJobAsync(int jobId);
/// <summary>
/// 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.
/// </summary>
Task<(List<NotificationLog> 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");
}
@@ -0,0 +1,17 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="PowderUsageLog"/> that adds a dynamic-filter
/// query for the Inventory Ledger usage tab on top of the generic CRUD interface.
/// </summary>
public interface IPowderUsageLogRepository : IRepository<PowderUsageLog>
{
/// <summary>
/// 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".
/// </summary>
Task<List<PowderUsageLog>> GetForLedgerAsync(int? itemId, DateTime? from, DateTime? to);
}
@@ -0,0 +1,44 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>Lightweight projection used by the Index KPI cards — avoids loading full entities for aggregate stats.</summary>
public record PurchaseOrderStats(int TotalCount, int OpenCount, decimal CommittedValue, int OverdueCount);
/// <summary>
/// Typed repository for <see cref="PurchaseOrder"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IPurchaseOrderRepository : IRepository<PurchaseOrder>
{
/// <summary>
/// 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.
/// </summary>
Task<PurchaseOrder?> LoadForViewAsync(int id, int companyId);
/// <summary>
/// Returns KPI aggregate stats for the Index view using a server-side projection so only three
/// columns are fetched rather than full entities.
/// </summary>
Task<PurchaseOrderStats> GetStatsAsync(int companyId);
/// <summary>
/// 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.
/// </summary>
Task<(List<PurchaseOrder> 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);
}
@@ -0,0 +1,53 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>Aggregate counts and totals for the Quotes Index stat cards.</summary>
public record QuoteIndexStats(int OpenCount, int ApprovedConvertedCount, decimal TotalValue);
/// <summary>
/// Typed repository for <see cref="Quote"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IQuoteRepository : IRepository<Quote>
{
/// <summary>
/// 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.
/// </summary>
Task<Quote?> LoadForDetailsAsync(int id);
/// <summary>
/// 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.
/// </summary>
Task<Quote?> GetByApprovalTokenAsync(string token);
/// <summary>
/// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
/// </summary>
Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId);
/// <summary>
/// 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.
/// </summary>
Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> approvedConvertedStatusIds);
/// <summary>
/// Loads quote items with their coat passes (InventoryItem + Vendor) and prep services for
/// PDF generation and quote→job conversion. Cheaper than <see cref="LoadForDetailsAsync"/>
/// because it skips the parent-quote navigations that callers already have.
/// </summary>
Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId);
/// <summary>
/// Returns the last quote number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted quotes) for sequential number generation.
/// </summary>
Task<string?> GetLastQuoteNumberByPrefixAsync(int companyId, string prefix);
}
@@ -0,0 +1,24 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Services;
/// <summary>
/// Writes platform-wide audit trail entries. <see cref="AuditLog"/> is not a
/// <see cref="BaseEntity"/> (no soft delete, no tenant filter), so it cannot use the generic
/// <see cref="IRepository{T}"/>; this service provides the only write path for audit records
/// and keeps <c>ApplicationDbContext</c> out of controller constructors.
/// </summary>
public interface IAuditLogService
{
/// <summary>
/// Persists <paramref name="entry"/> to the audit log immediately.
/// </summary>
Task LogAsync(AuditLog entry);
/// <summary>
/// Returns the most recent <paramref name="limit"/> audit log entries for the given user
/// where <c>EntityType == "ApplicationUser"</c>, ordered newest-first. Used by the
/// SuperAdmin user login history panel.
/// </summary>
Task<List<AuditLog>> GetUserActivityAsync(string userId, int limit = 50);
}
@@ -0,0 +1,26 @@
namespace PowderCoating.Core.Interfaces.Services;
/// <summary>
/// Destructive data-purge operations for the SuperAdmin company management UI.
/// All methods use bulk <c>ExecuteDeleteAsync</c> against <c>ApplicationDbContext</c> directly;
/// they are intentional exceptions to the IUnitOfWork pattern, mirroring
/// <c>DataPurgeController</c> and <c>AccountDataExportController</c> in the documented exceptions list.
/// </summary>
public interface ICompanyDataPurgeService
{
/// <summary>
/// Deletes all business-data tables for <paramref name="companyId"/> but does NOT delete the
/// company record or Identity users. The caller is responsible for deleting users via
/// <c>UserManager</c> and the company record via <see cref="IUnitOfWork"/> after this call.
/// <paramref name="companyUserIds"/> must be loaded beforehand so announcement-dismissal
/// records that reference users (rather than the company directly) can be cleaned up.
/// </summary>
Task DeleteAllBusinessDataAsync(int companyId, IReadOnlyList<string> companyUserIds);
/// <summary>
/// Deletes all business data for <paramref name="companyId"/> while preserving the company
/// record, users, operating costs, preferences, and lookup tables. Also clears the
/// QuickBooks migration state from <c>CompanyPreferences</c>. Used by the ResetData action.
/// </summary>
Task ResetBusinessDataAsync(int companyId);
}
@@ -0,0 +1,39 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Services;
/// <summary>
/// Wizard completion metadata surfaced in the company list view.
/// </summary>
public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? CompletedByName);
/// <summary>
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
/// </summary>
public record CompanyCountSummary(
IReadOnlyDictionary<int, int> JobCounts,
IReadOnlyDictionary<int, int> QuoteCounts,
IReadOnlyDictionary<int, int> CustomerCounts,
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo
);
/// <summary>
/// Read service for the SuperAdmin company list. Wraps queries that require
/// <c>IgnoreQueryFilters()</c>, dynamic search/sort, and cross-entity GROUP BY aggregations —
/// patterns the generic <see cref="IRepository{T}"/> cannot express.
/// </summary>
public interface ICompanyListService
{
/// <summary>
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
/// total unfiltered count for pagination.
/// </summary>
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
/// <summary>
/// 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.
/// </summary>
Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds);
}
@@ -0,0 +1,42 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Services;
/// <summary>
/// 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.
/// </summary>
public record DashboardIndexData(
List<Job> ActiveJobs,
decimal MonthlyRevenue,
List<Appointment> TodaysAppointments,
List<MaintenanceRecord> UpcomingMaintenance,
List<Quote> PendingQuotes,
List<Invoice> OpenInvoices,
decimal InvoicedThisMonth,
decimal CollectedThisMonth,
List<Payment> RecentPayments,
List<Quote> RecentQuotes,
List<Job> RecentJobs,
List<Job> JobsNeedingPowder,
List<Job> JobsWithOrderedPowder,
List<Bill> BillsDue,
string? TipOfTheDay
);
/// <summary>
/// Read-only service for the dashboard. All methods execute complex queries that require
/// ThenInclude chains or navigation-property predicates beyond what the generic
/// <see cref="IRepository{T}"/> can express. Lives in Infrastructure so <c>ApplicationDbContext</c>
/// is available; injected into the controller via DI.
/// </summary>
public interface IDashboardReadService
{
/// <summary>Fetches all data needed to render the tenant operator dashboard.</summary>
/// <param name="today">The local date used for date-range predicates (today, start-of-month, etc.).</param>
Task<DashboardIndexData> GetIndexDataAsync(DateTime today);
/// <summary>Returns the total count of tenant users (CompanyId > 0) for the SuperAdmin dashboard.</summary>
Task<int> GetTotalUserCountAsync();
}
@@ -0,0 +1,2 @@
// Moved to PowderCoating.Application.Interfaces.IFinancialReportService — Application layer owns DTO-returning service interfaces.
namespace PowderCoating.Core.Interfaces.Services;
@@ -0,0 +1,2 @@
// Moved to PowderCoating.Application.Interfaces.IOperationalReportService — Application layer owns DTO-returning service interfaces.
namespace PowderCoating.Core.Interfaces.Services;
@@ -0,0 +1,132 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGuidedActivationFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "FirstInvoiceCreatedAt",
table: "CompanyPreferences",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "FirstJobCreatedAt",
table: "CompanyPreferences",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "FirstQuoteCreatedAt",
table: "CompanyPreferences",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "FirstWorkflowCompleted",
table: "CompanyPreferences",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "FirstWorkflowCompletedAt",
table: "CompanyPreferences",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GuidedActivationDismissedAt",
table: "CompanyPreferences",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
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));
}
/// <inheritdoc />
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));
}
}
}
@@ -1969,6 +1969,24 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("EmailNotificationsEnabled")
.HasColumnType("bit");
b.Property<DateTime?>("FirstInvoiceCreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("FirstJobCreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("FirstQuoteCreatedAt")
.HasColumnType("datetime2");
b.Property<bool>("FirstWorkflowCompleted")
.HasColumnType("bit");
b.Property<DateTime?>("FirstWorkflowCompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("GuidedActivationDismissedAt")
.HasColumnType("datetime2");
b.Property<string>("InAccentColor")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -2017,6 +2035,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("NotifyOnQuoteApproval")
.HasColumnType("bit");
b.Property<string>("OnboardingPath")
.HasColumnType("nvarchar(max)");
b.Property<string>("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,
@@ -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;
/// <summary>
/// Typed repository for <see cref="Bill"/> that provides domain-specific multi-level
/// include queries previously expressed inline in <c>BillsController</c>.
/// </summary>
public class BillRepository : Repository<Bill>, IBillRepository
{
public BillRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<Bill?> 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();
}
/// <inheritdoc/>
public async Task<Bill?> LoadForEditAsync(int id)
{
return await _context.Bills
.Where(b => b.Id == id && !b.IsDeleted)
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<List<Bill>> 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();
}
/// <inheritdoc/>
public async Task<string?> GetLastBillNumberAsync(string prefix)
{
return await _context.Bills
.IgnoreQueryFilters()
.Where(b => b.BillNumber.StartsWith(prefix))
.OrderByDescending(b => b.BillNumber)
.Select(b => b.BillNumber)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<string?> GetLastPaymentNumberAsync(string prefix)
{
return await _context.BillPayments
.IgnoreQueryFilters()
.Where(p => p.PaymentNumber.StartsWith(prefix))
.OrderByDescending(p => p.PaymentNumber)
.Select(p => p.PaymentNumber)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<List<Bill>> 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();
}
}
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Customer"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public class CustomerRepository : Repository<Customer>, ICustomerRepository
{
public CustomerRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<Customer?> 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();
}
/// <inheritdoc/>
public async Task<Customer?> FindByEmailAsync(string email)
{
return await _context.Customers
.FirstOrDefaultAsync(c => c.Email == email && !c.IsDeleted);
}
}
@@ -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;
/// <summary>
/// Typed repository for <see cref="InventoryTransaction"/> that adds a dynamic-filter
/// ledger query on top of the generic <see cref="Repository{T}"/>.
/// </summary>
public class InventoryTransactionRepository : Repository<InventoryTransaction>, IInventoryTransactionRepository
{
public InventoryTransactionRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<List<InventoryTransaction>> 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();
}
}
@@ -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;
/// <summary>
/// Typed repository for <see cref="Invoice"/> that provides domain-specific multi-level
/// include queries previously expressed inline in <c>InvoicesController.LoadInvoiceForViewAsync</c>.
/// </summary>
public class InvoiceRepository : Repository<Invoice>, IInvoiceRepository
{
public InvoiceRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<Invoice?> LoadForViewAsync(int id)
{
return await _context.Set<Invoice>()
.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();
}
/// <inheritdoc/>
public async Task<Invoice?> GetForJobAsync(int jobId, bool includeDeleted = false)
{
var query = _context.Set<Invoice>().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();
}
/// <inheritdoc/>
public async Task<Invoice?> GetByPaymentTokenAsync(string token)
{
return await _context.Set<Invoice>()
.IgnoreQueryFilters()
.Include(i => i.Customer)
.Include(i => i.Job)
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
.FirstOrDefaultAsync(i => i.PaymentLinkToken == token && !i.IsDeleted);
}
/// <inheritdoc/>
public async Task<string?> GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix)
{
return await _context.Set<Invoice>()
.IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix))
.OrderByDescending(i => i.InvoiceNumber)
.Select(i => i.InvoiceNumber)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<List<Invoice>> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to)
{
return await _context.Set<Invoice>()
.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();
}
/// <inheritdoc/>
public async Task<List<Refund>> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to)
{
return await _context.Set<Refund>()
.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();
}
}
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="JobItemCoat"/> that adds ThenInclude-based load methods the
/// generic <see cref="Repository{T}"/> cannot express.
/// </summary>
public class JobItemCoatRepository : Repository<JobItemCoat>, IJobItemCoatRepository
{
public JobItemCoatRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<JobItemCoat?> 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);
}
/// <inheritdoc/>
public async Task<JobItemCoat?> LoadWithInventoryAsync(int id)
{
return await _context.JobItemCoats
.Include(c => c.InventoryItem)
.FirstOrDefaultAsync(c => c.Id == id);
}
/// <inheritdoc/>
public async Task<JobItemCoat?> LoadWithJobChainAsync(int id)
{
return await _context.JobItemCoats
.Include(c => c.JobItem).ThenInclude(i => i.Job)
.FirstOrDefaultAsync(c => c.Id == id);
}
/// <inheritdoc/>
public async Task<List<JobItemCoat>> 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();
}
}
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="JobPhoto"/> that provides inventory-specific photo
/// lookup queries requiring multi-level ThenInclude chains that the generic
/// <see cref="Repository{T}"/> cannot express.
/// </summary>
public class JobPhotoRepository : Repository<JobPhoto>, IJobPhotoRepository
{
public JobPhotoRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<List<JobPhoto>> 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();
}
/// <inheritdoc/>
public async Task<List<JobPhoto>> 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();
}
}
@@ -0,0 +1,190 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Job"/> that provides domain-specific multi-level
/// include queries that the generic <see cref="Repository{T}"/> 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.
/// </summary>
public class JobRepository : Repository<Job>, IJobRepository
{
public JobRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<List<Job>> 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();
}
/// <inheritdoc/>
public async Task<Job?> 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();
}
/// <inheritdoc/>
public async Task<Job?> 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();
}
/// <inheritdoc/>
public async Task<Job?> LoadForStatusChangeAsync(int id)
{
return await _context.Jobs
.Include(j => j.JobStatus)
.FirstOrDefaultAsync(j => j.Id == id && !j.IsDeleted);
}
/// <inheritdoc/>
public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId)
{
return await _context.JobChangeHistories
.Where(h => h.JobId == jobId && !h.IsDeleted)
.Include(h => h.ChangedBy)
.OrderByDescending(h => h.ChangedAt)
.AsNoTracking()
.ToListAsync();
}
/// <inheritdoc/>
public async Task<string?> 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();
}
/// <inheritdoc/>
public async Task<Job?> GetOrphanedConversionJobAsync(int quoteId, int companyId)
{
return await _context.Jobs
.IgnoreQueryFilters()
.FirstOrDefaultAsync(j => j.QuoteId == quoteId && j.CompanyId == companyId);
}
/// <inheritdoc/>
public async Task<List<Job>> 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();
}
/// <inheritdoc/>
public async Task<List<Job>> 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();
}
/// <inheritdoc/>
public async Task<Job?> 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();
}
/// <inheritdoc/>
public async Task<Job?> 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();
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="JobTemplate"/> that provides domain-specific multi-level
/// include queries that the generic <see cref="Repository{T}"/> 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.
/// </summary>
public class JobTemplateRepository : Repository<JobTemplate>, IJobTemplateRepository
{
public JobTemplateRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<JobTemplate?> 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();
}
/// <inheritdoc/>
public async Task<List<JobTemplate>> 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();
}
}
@@ -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;
/// <summary>
/// Typed repository for <see cref="NotificationLog"/> that provides IgnoreQueryFilters-based
/// lookups by entity FK (InvoiceId, QuoteId, JobId).
/// </summary>
public class NotificationLogRepository : Repository<NotificationLog>, INotificationLogRepository
{
public NotificationLogRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.InvoiceId == invoiceId)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
/// <inheritdoc/>
public async Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.InvoiceId == invoiceId)
.OrderByDescending(n => n.SentAt)
.ToListAsync();
/// <inheritdoc/>
public async Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.QuoteId == quoteId)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
/// <inheritdoc/>
public async Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.QuoteId == quoteId)
.OrderByDescending(n => n.SentAt)
.ToListAsync();
/// <inheritdoc/>
public async Task<NotificationLog?> GetLatestForJobAsync(int jobId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.JobId == jobId)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
/// <inheritdoc/>
public async Task<List<NotificationLog>> GetAllForJobAsync(int jobId) =>
await _context.Set<NotificationLog>()
.IgnoreQueryFilters()
.Where(n => n.JobId == jobId)
.OrderByDescending(n => n.SentAt)
.ToListAsync();
/// <inheritdoc/>
public async Task<(List<NotificationLog> 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);
}
}
@@ -0,0 +1,73 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// 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.
/// </summary>
public class PlainRepository<T> : IPlainRepository<T> where T : class
{
protected readonly ApplicationDbContext _context;
protected readonly DbSet<T> _dbSet;
public PlainRepository(ApplicationDbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
public virtual async Task<T?> GetByIdAsync(int id)
=> await _dbSet.FindAsync(id);
public virtual async Task<IEnumerable<T>> GetAllAsync()
=> await _dbSet.ToListAsync();
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
=> await _dbSet.Where(predicate).ToListAsync();
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate)
=> await _dbSet.FirstOrDefaultAsync(predicate);
public virtual async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate)
=> await _dbSet.AnyAsync(predicate);
public virtual async Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null)
=> predicate == null ? await _dbSet.CountAsync() : await _dbSet.CountAsync(predicate);
public virtual async Task<T> AddAsync(T entity)
{
await _dbSet.AddAsync(entity);
return entity;
}
public virtual async Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> 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);
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="PowderUsageLog"/> that adds a dynamic-filter
/// ledger query on top of the generic <see cref="Repository{T}"/>.
/// </summary>
public class PowderUsageLogRepository : Repository<PowderUsageLog>, IPowderUsageLogRepository
{
public PowderUsageLogRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<List<PowderUsageLog>> 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();
}
}
@@ -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;
/// <summary>
/// Typed repository for <see cref="PurchaseOrder"/> that provides domain-specific queries
/// previously expressed inline in <c>PurchaseOrdersController</c>.
/// </summary>
public class PurchaseOrderRepository : Repository<PurchaseOrder>, IPurchaseOrderRepository
{
public PurchaseOrderRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<PurchaseOrder?> LoadForViewAsync(int id, int companyId)
{
return await _context.Set<PurchaseOrder>()
.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();
}
/// <inheritdoc/>
public async Task<PurchaseOrderStats> GetStatsAsync(int companyId)
{
var today = DateTime.UtcNow.Date;
var items = await _context.Set<PurchaseOrder>()
.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)
);
}
/// <inheritdoc/>
public async Task<(List<PurchaseOrder> 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<PurchaseOrder>()
.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);
}
}
@@ -0,0 +1,111 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Quote"/> that provides domain-specific queries previously
/// scattered as inline EF expressions across <c>QuotesController</c> and
/// <c>QuoteApprovalController</c>.
/// </summary>
public class QuoteRepository : Repository<Quote>, IQuoteRepository
{
public QuoteRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<Quote?> 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;
}
/// <inheritdoc/>
public async Task<Quote?> 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);
}
/// <inheritdoc/>
public async Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId)
{
return await _context.QuoteChangeHistories
.Where(h => h.QuoteId == quoteId && !h.IsDeleted)
.Include(h => h.ChangedBy)
.OrderByDescending(h => h.ChangedAt)
.AsNoTracking()
.ToListAsync();
}
/// <inheritdoc/>
public async Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> 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));
}
/// <inheritdoc/>
public async Task<List<QuoteItem>> 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();
}
/// <inheritdoc/>
public async Task<string?> 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();
}
}
@@ -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<AiItemPrediction>? _aiItemPredictions;
// Powder Insights
private IRepository<PowderUsageLog>? _powderUsageLogs;
private IPowderUsageLogRepository? _powderUsageLogs;
// Core repositories
private IRepository<Customer>? _customers;
private IRepository<Job>? _jobs;
private ICustomerRepository? _customers;
private IJobRepository? _jobs;
private IRepository<JobDailyPriority>? _jobDailyPriorities;
private IRepository<JobItem>? _jobItems;
private IRepository<JobItemCoat>? _jobItemCoats;
private IJobItemCoatRepository? _jobItemCoats;
private IRepository<JobItemPrepService>? _jobItemPrepServices;
private IRepository<JobChangeHistory>? _jobChangeHistories;
private IRepository<Quote>? _quotes;
private IRepository<JobPrepService>? _jobPrepServices;
private IQuoteRepository? _quotes;
private IRepository<QuotePhoto>? _quotePhotos;
private IRepository<QuoteItem>? _quoteItems;
private IRepository<QuoteItemCoat>? _quoteItemCoats;
private IRepository<QuoteItemPrepService>? _quoteItemPrepServices;
private IRepository<QuoteChangeHistory>? _quoteChangeHistories;
private IRepository<InventoryItem>? _inventoryItems;
private IRepository<InventoryTransaction>? _inventoryTransactions;
private IInventoryTransactionRepository? _inventoryTransactions;
private IRepository<Equipment>? _equipment;
private IRepository<OvenCost>? _ovenCosts;
private IRepository<CompanyBlastSetup>? _blastSetups;
private IRepository<MaintenanceRecord>? _maintenanceRecords;
private IRepository<Vendor>? _vendors;
private IRepository<JobPhoto>? _jobPhotos;
private IJobPhotoRepository? _jobPhotos;
private IRepository<JobNote>? _jobNotes;
private IRepository<CustomerNote>? _customerNotes;
private IRepository<JobStatusHistory>? _jobStatusHistory;
@@ -87,20 +90,28 @@ public class UnitOfWork : IUnitOfWork
private IRepository<CatalogPriceCheckReport>? _catalogPriceCheckReports;
// Notifications
private IRepository<NotificationLog>? _notificationLogs;
private INotificationLogRepository? _notificationLogs;
private IRepository<NotificationTemplate>? _notificationTemplates;
// Subscription
private IRepository<SubscriptionPlanConfig>? _subscriptionPlanConfigs;
// Job Templates
private IRepository<JobTemplate>? _jobTemplates;
private IJobTemplateRepository? _jobTemplates;
private IRepository<JobTemplateItem>? _jobTemplateItems;
private IRepository<JobTemplateItemCoat>? _jobTemplateItemCoats;
private IRepository<JobTemplateItemPrepService>? _jobTemplateItemPrepServices;
// Platform content
private IPlainRepository<Announcement>? _announcements;
private IPlainRepository<BannedIp>? _bannedIps;
private IPlainRepository<DashboardTip>? _dashboardTips;
private IRepository<InAppNotification>? _inAppNotifications;
private IPlainRepository<ReleaseNote>? _releaseNotes;
// Bug Reports
private IRepository<BugReport>? _bugReports;
private IRepository<BugReportAttachment>? _bugReportAttachments;
private IRepository<ContactSubmission>? _contactSubmissions;
private IRepository<ManufacturerLookupPattern>? _manufacturerLookupPatterns;
@@ -109,7 +120,7 @@ public class UnitOfWork : IUnitOfWork
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
// Purchase Orders
private IRepository<PurchaseOrder>? _purchaseOrders;
private IPurchaseOrderRepository? _purchaseOrders;
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
// Oven Scheduling
@@ -117,14 +128,14 @@ public class UnitOfWork : IUnitOfWork
private IRepository<OvenBatchItem>? _ovenBatchItems;
// Invoices, Payments & Deposits
private IRepository<Invoice>? _invoices;
private IInvoiceRepository? _invoices;
private IRepository<InvoiceItem>? _invoiceItems;
private IRepository<Payment>? _payments;
private IRepository<Deposit>? _deposits;
// Expense Tracking / Accounts Payable
private IRepository<Account>? _accounts;
private IRepository<Bill>? _bills;
private IBillRepository? _bills;
private IRepository<BillLineItem>? _billLineItems;
private IRepository<BillPayment>? _billPayments;
private IRepository<Expense>? _expenses;
@@ -166,17 +177,17 @@ public class UnitOfWork : IUnitOfWork
// Powder Insights
/// <summary>Repository for <see cref="PowderUsageLog"/> records capturing per-coat powder consumption; used by powder-usage analytics.</summary>
public IRepository<PowderUsageLog> PowderUsageLogs =>
_powderUsageLogs ??= new Repository<PowderUsageLog>(_context);
public IPowderUsageLogRepository PowderUsageLogs =>
_powderUsageLogs ??= new PowderUsageLogRepository(_context);
// Core repositories
/// <summary>Repository for <see cref="Customer"/> records (commercial and non-commercial); tenant-filtered with soft delete.</summary>
public IRepository<Customer> Customers =>
_customers ??= new Repository<Customer>(_context);
public ICustomerRepository Customers =>
_customers ??= new CustomerRepository(_context);
/// <summary>Repository for <see cref="Job"/> records progressing through the 16-status lifecycle; tenant-filtered with soft delete.</summary>
public IRepository<Job> Jobs =>
_jobs ??= new Repository<Job>(_context);
public IJobRepository Jobs =>
_jobs ??= new JobRepository(_context);
/// <summary>Repository for <see cref="JobDailyPriority"/> overrides that let supervisors re-order the shop floor queue.</summary>
public IRepository<JobDailyPriority> JobDailyPriorities =>
@@ -187,16 +198,22 @@ public class UnitOfWork : IUnitOfWork
_jobItems ??= new Repository<JobItem>(_context);
/// <summary>Repository for <see cref="JobItemCoat"/> powder coat passes; tenant-filtered with soft delete.</summary>
public IRepository<JobItemCoat> JobItemCoats =>
_jobItemCoats ??= new Repository<JobItemCoat>(_context);
public IJobItemCoatRepository JobItemCoats =>
_jobItemCoats ??= new JobItemCoatRepository(_context);
public IRepository<JobItemPrepService> JobItemPrepServices =>
_jobItemPrepServices ??= new Repository<JobItemPrepService>(_context);
/// <summary>Repository for <see cref="JobChangeHistory"/> audit entries; tenant-filtered with soft delete.</summary>
public IRepository<JobChangeHistory> JobChangeHistories =>
_jobChangeHistories ??= new Repository<JobChangeHistory>(_context);
/// <summary>Repository for <see cref="JobPrepService"/> job-level prep service assignments; tenant-filtered with soft delete.</summary>
public IRepository<JobPrepService> JobPrepServices =>
_jobPrepServices ??= new Repository<JobPrepService>(_context);
/// <summary>Repository for <see cref="Quote"/> records with multi-item pricing; tenant-filtered with soft delete.</summary>
public IRepository<Quote> Quotes =>
_quotes ??= new Repository<Quote>(_context);
public IQuoteRepository Quotes =>
_quotes ??= new QuoteRepository(_context);
/// <summary>Repository for <see cref="QuotePhoto"/> AI photo uploads; tenant-filtered with soft delete.</summary>
public IRepository<QuotePhoto> QuotePhotos =>
@@ -223,8 +240,8 @@ public class UnitOfWork : IUnitOfWork
_inventoryItems ??= new Repository<InventoryItem>(_context);
/// <summary>Repository for <see cref="InventoryTransaction"/> stock movements; tenant-filtered with soft delete.</summary>
public IRepository<InventoryTransaction> InventoryTransactions =>
_inventoryTransactions ??= new Repository<InventoryTransaction>(_context);
public IInventoryTransactionRepository InventoryTransactions =>
_inventoryTransactions ??= new InventoryTransactionRepository(_context);
/// <summary>Repository for <see cref="Equipment"/> records (ovens, sandblasters, booths); tenant-filtered with soft delete.</summary>
public IRepository<Equipment> Equipment =>
@@ -247,8 +264,8 @@ public class UnitOfWork : IUnitOfWork
_vendors ??= new Repository<Vendor>(_context);
/// <summary>Repository for <see cref="JobPhoto"/> attachments; tenant-filtered with soft delete.</summary>
public IRepository<JobPhoto> JobPhotos =>
_jobPhotos ??= new Repository<JobPhoto>(_context);
public IJobPhotoRepository JobPhotos =>
_jobPhotos ??= new JobPhotoRepository(_context);
/// <summary>Repository for <see cref="JobNote"/> free-text staff notes on jobs; tenant-filtered with soft delete.</summary>
public IRepository<JobNote> JobNotes =>
@@ -350,9 +367,9 @@ public class UnitOfWork : IUnitOfWork
_catalogPriceCheckReports ??= new Repository<CatalogPriceCheckReport>(_context);
// Notifications
/// <summary>Repository for <see cref="NotificationLog"/> outbound notification audit records; tenant-filtered with soft delete.</summary>
public IRepository<NotificationLog> NotificationLogs =>
_notificationLogs ??= new Repository<NotificationLog>(_context);
/// <summary>Repository for <see cref="NotificationLog"/> outbound notification audit records; provides IgnoreQueryFilters lookups by InvoiceId, QuoteId, and JobId for notification history panels.</summary>
public INotificationLogRepository NotificationLogs =>
_notificationLogs ??= new NotificationLogRepository(_context);
/// <summary>Repository for <see cref="NotificationTemplate"/> per-company channel template overrides; unique on (CompanyId, Type, Channel).</summary>
public IRepository<NotificationTemplate> NotificationTemplates =>
@@ -363,11 +380,35 @@ public class UnitOfWork : IUnitOfWork
public IRepository<SubscriptionPlanConfig> SubscriptionPlanConfigs =>
_subscriptionPlanConfigs ??= new Repository<SubscriptionPlanConfig>(_context);
// Platform content
/// <summary>Repository for <see cref="Announcement"/> platform-wide announcements; no tenant filter, no soft delete.</summary>
public IPlainRepository<Announcement> Announcements =>
_announcements ??= new PlainRepository<Announcement>(_context);
/// <summary>Repository for <see cref="BannedIp"/> IP ban records; no tenant filter, no soft delete.</summary>
public IPlainRepository<BannedIp> BannedIps =>
_bannedIps ??= new PlainRepository<BannedIp>(_context);
/// <summary>Repository for <see cref="DashboardTip"/> rotating tip-of-the-day entries; no tenant filter, no soft delete.</summary>
public IPlainRepository<DashboardTip> DashboardTips =>
_dashboardTips ??= new PlainRepository<DashboardTip>(_context);
/// <summary>Repository for <see cref="InAppNotification"/> bell-notification records; tenant-filtered with soft delete.</summary>
public IRepository<InAppNotification> InAppNotifications =>
_inAppNotifications ??= new Repository<InAppNotification>(_context);
/// <summary>Repository for <see cref="ReleaseNote"/> platform changelog entries; no tenant filter, no soft delete.</summary>
public IPlainRepository<ReleaseNote> ReleaseNotes =>
_releaseNotes ??= new PlainRepository<ReleaseNote>(_context);
// Bug Reports
/// <summary>Repository for <see cref="BugReport"/> user-submitted bug reports; tenant-filtered with soft delete.</summary>
public IRepository<BugReport> BugReports =>
_bugReports ??= new Repository<BugReport>(_context);
public IRepository<BugReportAttachment> BugReportAttachments =>
_bugReportAttachments ??= new Repository<BugReportAttachment>(_context);
// Contact Us
/// <summary>Repository for <see cref="ContactSubmission"/> contact form submissions; platform admins see all, company users see their own.</summary>
public IRepository<ContactSubmission> ContactSubmissions =>
@@ -388,8 +429,8 @@ public class UnitOfWork : IUnitOfWork
// Job Templates
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
public IRepository<JobTemplate> JobTemplates =>
_jobTemplates ??= new Repository<JobTemplate>(_context);
public IJobTemplateRepository JobTemplates =>
_jobTemplates ??= new JobTemplateRepository(_context);
/// <summary>Repository for <see cref="JobTemplateItem"/> item definitions within a job template.</summary>
public IRepository<JobTemplateItem> JobTemplateItems =>
@@ -405,8 +446,8 @@ public class UnitOfWork : IUnitOfWork
// Purchase Orders
/// <summary>Repository for <see cref="PurchaseOrder"/> vendor purchase orders; tenant-filtered with soft delete.</summary>
public IRepository<PurchaseOrder> PurchaseOrders =>
_purchaseOrders ??= new Repository<PurchaseOrder>(_context);
public IPurchaseOrderRepository PurchaseOrders =>
_purchaseOrders ??= new PurchaseOrderRepository(_context);
/// <summary>Repository for <see cref="PurchaseOrderItem"/> line-items on a purchase order; cascade-deleted with the PO.</summary>
public IRepository<PurchaseOrderItem> PurchaseOrderItems =>
@@ -423,8 +464,8 @@ public class UnitOfWork : IUnitOfWork
// Invoices, Payments & Deposits
/// <summary>Repository for <see cref="Invoice"/> customer invoices (1:1 with Job); tenant-filtered with soft delete.</summary>
public IRepository<Invoice> Invoices =>
_invoices ??= new Repository<Invoice>(_context);
public IInvoiceRepository Invoices =>
_invoices ??= new InvoiceRepository(_context);
/// <summary>Repository for <see cref="InvoiceItem"/> line-items on an invoice; tenant-filtered with soft delete.</summary>
public IRepository<InvoiceItem> InvoiceItems =>
@@ -447,8 +488,8 @@ public class UnitOfWork : IUnitOfWork
_accounts ??= new Repository<Account>(_context);
/// <summary>Repository for <see cref="Bill"/> vendor bills (accounts payable); tenant-filtered with soft delete.</summary>
public IRepository<Bill> Bills =>
_bills ??= new Repository<Bill>(_context);
public IBillRepository Bills =>
_bills ??= new BillRepository(_context);
/// <summary>Repository for <see cref="BillLineItem"/> expense line-items on a vendor bill; each assigned to a chart-of-accounts entry.</summary>
public IRepository<BillLineItem> BillLineItems =>
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements <see cref="IAiUsageReportService"/> by querying <c>AiUsageLogs</c> and
/// <c>QuotePhotos</c> directly via <c>ApplicationDbContext</c>. 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.
/// </summary>
public class AiUsageReportService : IAiUsageReportService
{
private readonly ApplicationDbContext _context;
public AiUsageReportService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task<AiUsageReportData> 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);
}
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Concrete implementation of <see cref="IAuditLogService"/> that writes <see cref="AuditLog"/>
/// entries via <see cref="ApplicationDbContext"/> directly. <c>AuditLog</c> does not inherit
/// from <c>BaseEntity</c> so it cannot be managed through the generic repository; this service
/// owns that write path and keeps <c>ApplicationDbContext</c> out of controller constructors.
/// </summary>
public class AuditLogService : IAuditLogService
{
private readonly ApplicationDbContext _context;
public AuditLogService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task LogAsync(AuditLog entry)
{
_context.AuditLogs.Add(entry);
await _context.SaveChangesAsync();
}
/// <inheritdoc/>
public async Task<List<AuditLog>> 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();
}
}
@@ -0,0 +1,180 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements <see cref="ICompanyDataPurgeService"/> via bulk <c>ExecuteDeleteAsync</c> against
/// <see cref="ApplicationDbContext"/> directly. This is an intentional exception to the
/// IUnitOfWork pattern — identical to the rationale for <c>DataPurgeController</c> in the
/// documented permanent exceptions list. Each <c>ExecuteDeleteAsync</c> call commits immediately
/// at the database level so no <c>SaveChangesAsync</c> is needed for the bulk tiers.
/// </summary>
public class CompanyDataPurgeService : ICompanyDataPurgeService
{
private readonly ApplicationDbContext _context;
public CompanyDataPurgeService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task DeleteAllBusinessDataAsync(int companyId, IReadOnlyList<string> 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
}
/// <inheritdoc/>
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();
}
}
}
@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements <see cref="ICompanyListService"/> using <see cref="ApplicationDbContext"/> directly.
/// Queries require <c>IgnoreQueryFilters()</c> (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 <see cref="PowderCoating.Core.Interfaces.IRepository{T}"/>.
/// </summary>
public class CompanyListService : ICompanyListService
{
private readonly ApplicationDbContext _context;
public CompanyListService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task<(List<Company> 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);
}
/// <inheritdoc/>
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> 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);
}
}
@@ -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;
/// <summary>
/// Implements <see cref="IDashboardReadService"/> using <see cref="ApplicationDbContext"/> directly.
/// All queries require ThenInclude chains or navigation-property predicates (e.g. JobStatus.StatusCode)
/// that cannot be expressed through the generic <see cref="PowderCoating.Core.Interfaces.IRepository{T}"/>.
/// </summary>
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;
}
/// <inheritdoc/>
public async Task<DashboardIndexData> 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
);
}
/// <inheritdoc/>
public async Task<int> GetTotalUserCountAsync()
{
return await _context.Users
.Where(u => u.CompanyId > 0)
.CountAsync();
}
}
@@ -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;
/// <summary>
/// Implements financial aggregate reports (P&amp;L, Balance Sheet, AR Aging, Sales &amp; 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 <c>docs/DATA_ACCESS_ARCHITECTURE.md</c> for the full migration plan.
/// </summary>
public class FinancialReportService : IFinancialReportService
{
private readonly ApplicationDbContext _context;
public FinancialReportService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task<ProfitAndLossDto> 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<int, decimal>();
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<FinancialReportLine>();
var expenseLines = new List<FinancialReportLine>();
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),
};
}
/// <inheritdoc/>
public async Task<BalanceSheetDto> 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,
};
}
/// <inheritdoc/>
public async Task<ArAgingReportDto> 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<ArAgingCustomerDto>();
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),
};
}
/// <inheritdoc/>
public async Task<SalesIncomeReportDto> 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,
};
}
/// <summary>
/// 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.
/// </summary>
private async Task<string> GetCompanyNameAsync(int companyId)
{
if (companyId <= 0) return "Your Company";
var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId);
return company?.CompanyName ?? "Your Company";
}
}
@@ -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;
/// <summary>
/// Implements operational aggregate reports using direct DbContext access with AsNoTracking.
/// Query logic is migrated here from <c>ReportsController</c> as each report action is
/// converted during Phase 2/3 of the data-access architecture migration.
/// See <c>docs/DATA_ACCESS_ARCHITECTURE.md</c> for the full migration plan.
/// </summary>
public class OperationalReportService : IOperationalReportService
{
private readonly ApplicationDbContext _context;
public OperationalReportService(ApplicationDbContext context)
{
_context = context;
}
/// <inheritdoc/>
public async Task<JobCycleTimeReport> 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<string, (string DisplayName, List<double> 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<double>());
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<double>());
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);
}
/// <inheritdoc/>
public async Task<PowderUsageReport> 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);
}
/// <inheritdoc/>
public async Task<List<Bill>> 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();
}
/// <inheritdoc/>
public async Task<List<Expense>> GetAllExpensesAsync()
{
return await _context.Expenses
.Include(e => e.ExpenseAccount)
.Where(e => !e.IsDeleted)
.AsNoTracking()
.ToListAsync();
}
/// <inheritdoc/>
public async Task<List<JobStatusHistory>> GetAllJobStatusHistoryAsync()
{
return await _context.JobStatusHistory
.Include(h => h.FromStatus)
.Include(h => h.ToStatus)
.AsNoTracking()
.ToListAsync();
}
}
@@ -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
}
}
@@ -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<IActionResult> 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<PowderCoating.Core.Entities.Expense>()
.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<PowderCoating.Core.Entities.Bill>()
.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();
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<AiQuickQuoteController> _logger;
@@ -27,14 +25,12 @@ public class AiQuickQuoteController : Controller
IUnitOfWork unitOfWork,
IAiQuickQuoteService aiService,
IPricingCalculationService pricingService,
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
ILogger<AiQuickQuoteController> 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)
@@ -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<AiUsageReportController> _logger;
public AiUsageReportController(ApplicationDbContext context, ILogger<AiUsageReportController> logger)
public AiUsageReportController(
IUnitOfWork unitOfWork,
IAiUsageReportService aiUsageReport,
ILogger<AiUsageReportController> logger)
{
_context = context;
_unitOfWork = unitOfWork;
_aiUsageReport = aiUsageReport;
_logger = logger;
}
/// <summary>
/// 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).
/// </summary>
public async Task<IActionResult> 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<PowderCoating.Core.Entities.SubscriptionPlanConfig>()
.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);
@@ -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
/// </summary>
public async Task<IActionResult> Index()
{
var announcements = await _db.Announcements
var announcements = (await _unitOfWork.Announcements.GetAllAsync())
.OrderByDescending(a => a.CreatedAt)
.ToListAsync();
.ToList();
return View(announcements);
}
/// <summary>
/// Shows the announcement creation form with sensible defaults: starts now, dismissible, and active.
/// </summary>
public IActionResult Create()
public async Task<IActionResult> 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<IActionResult> 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
/// </summary>
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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));
}
/// <summary>
/// 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.
/// </summary>
private async Task DispatchNotificationsAsync(Announcement model)
{
IQueryable<PowderCoating.Core.Entities.Company> 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");
}
/// <summary>
/// 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.
/// </summary>
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();
}
}
@@ -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.
/// </summary>
// 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
{
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<BannedIpsController> _logger;
public BannedIpsController(
ApplicationDbContext db,
IUnitOfWork unitOfWork,
UserManager<ApplicationUser> userManager,
ILogger<BannedIpsController> logger)
{
_db = db;
_unitOfWork = unitOfWork;
_userManager = userManager;
_logger = logger;
}
/// <summary>Lists all banned IPs, showing active and expired separately.</summary>
// GET: BannedIps
public async Task<IActionResult> 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.
/// </summary>
// POST: BannedIps/Add
[HttpPost]
[ValidateAntiForgeryToken]
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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
}
/// <summary>Lifts a ban immediately by marking IsActive = false.</summary>
// POST: BannedIps/Lift/5
[HttpPost]
[ValidateAntiForgeryToken]
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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
}
/// <summary>Permanently deletes a ban record.</summary>
// POST: BannedIps/Delete/5
[HttpPost]
[ValidateAntiForgeryToken]
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
/// <summary>Returns the requesting client's IP so the admin can pre-fill it quickly.</summary>
// GET: BannedIps/MyIp
public IActionResult MyIp()
{
return Json(new { ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown" });
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<BillsController> _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<ApplicationUser> userManager,
ILogger<BillsController> 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<Core.Entities.Expense>()
.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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<BillDto>(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<string> 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<string> 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))
@@ -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<ApplicationUser> _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<ApplicationUser> 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<BugReportStatus>(statusFilter, out var status))
query = query.Where(r => r.Status == status);
allReports = allReports.Where(r => r.Status == status);
if (!string.IsNullOrWhiteSpace(priorityFilter) &&
Enum.TryParse<BugReportPriority>(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<List<BugReportDto>>(items);
@@ -291,12 +284,10 @@ public class BugReportController : Controller
var dto = _mapper.Map<EditBugReportDto>(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<List<BugReportAttachmentDto>>(attachments);
@@ -319,10 +310,7 @@ public class BugReportController : Controller
[HttpGet]
public async Task<IActionResult> 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();
@@ -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<ApplicationUser> _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<CompaniesController> _logger;
@@ -33,7 +35,9 @@ public class CompaniesController : Controller
IMapper mapper,
UserManager<ApplicationUser> userManager,
ISeedDataService seedDataService,
ApplicationDbContext context,
ICompanyListService companyList,
ICompanyDataPurgeService companyPurge,
IAuditLogService auditLog,
IInAppNotificationService inApp,
ILogger<CompaniesController> 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<List<CompanyListDto>>(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<IActionResult> 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.
/// </summary>
// 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<IActionResult> 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<dynamic>();
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
{
@@ -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<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
@@ -42,7 +43,7 @@ public class CompanySettingsController : Controller
ILookupCacheService lookupCache,
IStripeConnectService stripeConnect,
IConfiguration configuration,
ApplicationDbContext context,
IAuditLogService auditLog,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> 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<CompanySettingsDto>(company);
// Populate AllowOnlinePayments from subscription plan config
var planConfig = await _context.Set<SubscriptionPlanConfig>()
.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}. " +
@@ -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<CompanyUsersController> _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<CompanyUsersController> 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();
}
}
@@ -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<ApplicationUser> _userManager;
public CustomersController(
@@ -35,7 +34,6 @@ public class CustomersController : Controller
INotificationService notificationService,
ISubscriptionService subscriptionService,
ITenantContext tenantContext,
ApplicationDbContext context,
UserManager<ApplicationUser> 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);
}
@@ -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<DashboardController> _logger;
private readonly ApplicationDbContext _context;
private readonly IDashboardReadService _dashboardRead;
private readonly ITenantContext _tenantContext;
private readonly ICompanyConfigHealthService _configHealth;
private readonly UserManager<ApplicationUser> _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<DashboardController> logger, ApplicationDbContext context, ITenantContext tenantContext, ICompanyConfigHealthService configHealth)
public DashboardController(
IUnitOfWork unitOfWork,
ILogger<DashboardController> logger,
IDashboardReadService dashboardRead,
ITenantContext tenantContext,
ICompanyConfigHealthService configHealth,
UserManager<ApplicationUser> userManager,
ISubscriptionService subscriptionService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_context = context;
_dashboardRead = dashboardRead;
_tenantContext = tenantContext;
_configHealth = configHealth;
_userManager = userManager;
_subscriptionService = subscriptionService;
}
/// <summary>
@@ -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"
};
}
/// <summary>
/// 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.
/// </summary>
private async Task<ShopProgressWidgetViewModel?> 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<ShopProgressItem?>
{
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() };
}
/// <summary>
/// Records receipt of a powder shipment against an existing powder order. Sets
/// <c>PowderReceived</c>, <c>PowderReceivedLbs</c>, and <c>PowderReceivedAt</c> on the coat,
/// and — when the coat is linked to an inventory item — increases <c>QuantityOnHand</c> and
/// writes a <c>Purchase</c> <see cref="PowderCoating.Core.Entities.InventoryTransaction"/> so
/// the stock movement is fully traceable. Company ownership is verified through the parent job
/// because <c>JobItemCoat</c> carries no <c>CompanyId</c> of its own.
/// writes a <c>Purchase</c> <see cref="InventoryTransaction"/> so the stock movement is fully
/// traceable. Company ownership is verified through the parent job because <c>JobItemCoat</c>
/// carries no <c>CompanyId</c> of its own.
/// </summary>
[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<PowderCoating.Core.Entities.InventoryTransaction>().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<InventoryTransaction>().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)));
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
// GET: /DashboardTips
public async Task<IActionResult> 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);
}
/// <summary>Returns the Create form with an empty <see cref="DashboardTip"/> model.</summary>
// GET: /DashboardTips/Create
public IActionResult Create() => View(new DashboardTip());
/// <summary>
/// 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
/// <c>[Required]</c> attributes) to ensure a meaningful error message is shown.
/// </summary>
// POST: /DashboardTips/Create
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
/// <summary>Returns the Edit form for an existing tip, or 404 if not found.</summary>
// GET: /DashboardTips/Edit/5
public async Task<IActionResult> 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);
}
/// <summary>
/// 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.
/// </summary>
// POST: /DashboardTips/Edit/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
/// <summary>
/// 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.
/// </summary>
// POST: /DashboardTips/Delete/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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
/// <summary>
/// Flips the <c>IsActive</c> 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.
/// </summary>
// POST: /DashboardTips/ToggleActive/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<DepositsController> _logger;
private readonly ApplicationDbContext _context;
public DepositsController(
IUnitOfWork unitOfWork,
UserManager<ApplicationUser> userManager,
ILogger<DepositsController> logger,
ApplicationDbContext context)
ILogger<DepositsController> 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<Deposit>()
.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)
@@ -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;
/// <summary>
/// SuperAdmin-only tool for sending platform-wide broadcast emails to tenant
/// company contacts. Emails are sent one at a time via <see cref="IEmailService"/>
/// rather than bulk API because each message requires a personalised unsubscribe link
/// containing the company's unique <c>MarketingUnsubscribeToken</c>.
/// </summary>
// 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<string> 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<EmailBroadcastController> _logger;
public EmailBroadcastController(
ApplicationDbContext db,
IEmailService emailService,
IPlatformSettingsService platformSettings,
ILogger<EmailBroadcastController> logger)
{
_db = db;
_emailService = emailService;
_platformSettings = platformSettings;
_logger = logger;
}
/// <summary>
/// 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.
/// </summary>
public async Task<IActionResult> Index()
{
await PopulateViewBag();
return View(new BroadcastForm());
}
/// <summary>Returns JSON count of recipients for the current filter — used for the live preview.</summary>
[HttpGet]
public async Task<IActionResult> RecipientCount(string target, string? plan, int[]? companyIds)
public IActionResult Index() => View(new AdminEmailComposeModel());
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
/// <summary>
/// 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.
/// <para>
/// Each email body is HTML-encoded (then line-breaks converted to
/// <c>&lt;br&gt;</c>) 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.
/// </para>
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Send(BroadcastForm form)
public IActionResult BackToCompose(AdminEmailComposeModel form)
{
if (!ModelState.IsValid)
NormalizeComposeModel(form);
return View("Index", form);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> BackToSelectCompanies(AdminEmailSelectionModel form)
{
NormalizeComposeModel(form);
return View("SelectCompanies", await BuildSelectionModelAsync(form));
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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", "<br>");
foreach (var (email, name, unsubToken) in recipients)
{
var unsubUrl = $"{baseUrl}/Unsubscribe/BroadcastEmail/{unsubToken}";
var htmlBody = $@"
<div style=""font-family:sans-serif;max-width:600px;margin:0 auto"">
<p>{encodedBody}</p>
<hr style=""border:none;border-top:1px solid #eee;margin:24px 0"">
<p style=""font-size:12px;color:#999"">
This message was sent by the Powder Coating Logix platform team.<br>
<a href=""{unsubUrl}"" style=""color:#999"">Unsubscribe from platform announcements</a>
</p>
</div>";
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));
}
/// <summary>
/// Builds the list of (email, name, unsubscribe-token) tuples for the given
/// targeting criteria. Companies are excluded when <c>MarketingEmailOptOut</c>
/// is true — honouring prior unsubscribes — or when <c>PrimaryContactEmail</c>
/// is missing. The "specific" target requires at least one <c>companyIds</c>
/// entry and returns an empty list otherwise to prevent accidental all-company sends.
/// <c>IgnoreQueryFilters()</c> is required because this query spans companies.
/// </summary>
private async Task<List<(string Email, string Name, string UnsubToken)>> 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<AdminEmailSelectionModel> BuildSelectionModelAsync(AdminEmailComposeModel form)
{
var selectedIds = form is AdminEmailSelectionModel selection && selection.CompanyIds != null
? selection.CompanyIds
: Array.Empty<int>();
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<AdminEmailPreviewModel> 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)
}
};
}
/// <summary>
/// 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 <see cref="Index"/> and the
/// validation-failure branch of <see cref="Send"/>.
/// </summary>
private async Task PopulateViewBag()
private async Task<List<AdminEmailCompanyOption>> LoadCompanyOptionsAsync(IReadOnlyCollection<int> 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<List<AdminEmailRecipientContext>> LoadRecipientContextsAsync(IReadOnlyCollection<int> 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<Dictionary<int, CompanyAdminLookup>> LoadCompanyAdminLookupAsync(IReadOnlyCollection<int> 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<string?> 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<string, string> 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 $"""
<div style="font-family:Arial,Helvetica,sans-serif;max-width:700px;margin:0 auto;color:#1f2937;line-height:1.6;">
{renderedHtmlBody}
</div>
""";
}
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("<div", "<p", StringComparison.OrdinalIgnoreCase)
.Replace("</div>", "</p>", 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 "<br>";
if (tagName == "a")
{
if (isClosingTag)
return "</a>";
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 $"""<a href="{encodedHref}" target="_blank" rel="noopener noreferrer">""";
}
return isClosingTag ? $"</{tagName}>" : $"<{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<AdminEmailCompanyOption> AvailableCompanies { get; set; } = [];
}
public class AdminEmailSendRequest : AdminEmailSelectionModel;
public class AdminEmailPreviewModel : AdminEmailSendRequest
{
public List<AdminEmailRecipientPreviewRow> 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;
}
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<ExpensesController> _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<ApplicationUser> userManager,
ILogger<ExpensesController> logger,
ApplicationDbContext context,
IAzureBlobStorageService blobStorage,
IOptions<StorageSettings> 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<IActionResult> 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<List<ExpenseListDto>>(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<ExpenseDto>(expense));
@@ -445,12 +434,11 @@ public class ExpensesController : Controller
private async Task<string> 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))
@@ -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<GuidedActivationController> _logger;
public GuidedActivationController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
ILogger<GuidedActivationController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<CompanyPreferences> 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<Customer> 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;
}
}
@@ -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<PowderCoating.Core.Entities.InAppNotification> 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
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> Recent()
{
IQueryable<PowderCoating.Core.Entities.InAppNotification> 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
}
/// <summary>
/// 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.
/// </summary>
[HttpGet]
public async Task<IActionResult> Unread()
{
IQueryable<PowderCoating.Core.Entities.InAppNotification> 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
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
public async Task<IActionResult> 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 });
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
public async Task<IActionResult> MarkAllRead()
{
var now = DateTime.UtcNow;
List<PowderCoating.Core.Entities.InAppNotification> 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 });
}
}
@@ -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<ApplicationUser> _userManager;
public InventoryController(
@@ -40,7 +37,6 @@ public class InventoryController : Controller
IMeasurementConversionService measurementService,
IInventoryAiLookupService aiLookupService,
ISubscriptionService subscriptionService,
ApplicationDbContext context,
UserManager<ApplicationUser> 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<object>(), totalCount = 0, page, pageSize });
IQueryable<JobPhoto> 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<InventoryItemDto>(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<InventoryTransactionType>(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<InventoryTransactionType>(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<string, (int Id, string JobNumber)>();
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)
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<InvoicesController> _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<ApplicationUser> userManager,
ILogger<InvoicesController> 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.
/// </summary>
public async Task<IActionResult> Details(int? id)
public async Task<IActionResult> 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<SubscriptionPlanConfig>()
.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 = "Youve 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.
/// </summary>
public async Task<IActionResult> Create(int? jobId)
public async Task<IActionResult> 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<Invoice>()
.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<int, CatalogItem>();
// Fall back to the default revenue account (4000) if a catalog item has no specific account
var defaultRevenueAccount = await _context.Set<Account>()
.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<PowderCoating.Core.Entities.Quote>()
.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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateInvoiceDto dto)
public async Task<IActionResult> 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<Invoice>()
.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<Deposit>()
.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<Invoice>()
.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<IActionResult> 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
// -----------------------------------------------------------------------
/// <summary>
/// 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 <see cref="IInvoiceRepository.LoadForViewAsync"/> which expresses the full
/// eight-table include chain. Returns null if not found or soft-deleted.
/// </summary>
private async Task<Invoice?> LoadInvoiceForViewAsync(int id)
{
return await _context.Set<Invoice>()
.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<Invoice?> LoadInvoiceForViewAsync(int id) =>
await _unitOfWork.Invoices.LoadForViewAsync(id);
/// <summary>
/// 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<CreditMemo>()
.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
/// </summary>
private async Task<string> 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<Invoice>()
.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<CatalogItem>()
.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;
}
/// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active OtherCurrentLiability with "tax" in the name.</summary>
/// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active Liability account with "tax" in the name.</summary>
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
{
var taxAccount = await _context.Set<Account>()
.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
/// </summary>
private async Task<string?> 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<SubscriptionPlanConfig>()
.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<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
{
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
}
private async Task<bool> 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;
}
}
@@ -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;
}
/// <summary>
/// Displays all non-deleted job templates for the current company, ordered by name, with their
/// linked customer and item counts. Uses the direct <c>_context</c> query (bypassing
/// <c>IUnitOfWork</c>) to leverage EF Core's filtered includes (<c>.Include(t => t.Items)</c>)
/// 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.
/// </summary>
public async Task<IActionResult> 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.
/// </summary>
public async Task<IActionResult> 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
/// </summary>
public async Task<IActionResult> 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<IActionResult> 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
{
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<JobsController> _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<ApplicationUser> userManager,
ILogger<JobsController> 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.
/// </summary>
public async Task<IActionResult> Board(bool showTerminal = false)
public async Task<IActionResult> 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<IActionResult> 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.
/// </summary>
public async Task<IActionResult> Details(int? id)
public async Task<IActionResult> 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<JobDto>(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<List<JobChangeHistoryDto>>(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<Invoice>()
.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<Deposit>()
.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<InventoryTransaction>()
.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<JobDto>(job);
@@ -918,7 +859,7 @@ public class JobsController : Controller
/// (pre-configured job types with standard items). If <paramref name="customerId"/> is provided,
/// the customer dropdown is pre-selected. The wizard is the same multi-step UI as the quote wizard.
/// </summary>
public async Task<IActionResult> Create(int? customerId, int? templateId)
public async Task<IActionResult> 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<int, string> 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
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateJobDto dto)
public async Task<IActionResult> 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
/// <summary>
/// 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 <see cref="Job.AssignedUserId"/>, 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.
/// </summary>
[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<JobPhotoDto>(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<IActionResult> 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<JobItemPrepService>().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<CompanyPreferences?> GetCompanyPreferencesAsync(int companyId)
{
return await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
}
private async Task<Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel?> 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<bool> 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
@@ -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<JobsPriorityController> _logger;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITenantContext _tenantContext;
@@ -27,14 +25,12 @@ public class JobsPriorityController : Controller
public JobsPriorityController(
IUnitOfWork unitOfWork,
ApplicationDbContext context,
ILogger<JobsPriorityController> logger,
UserManager<ApplicationUser> userManager,
ITenantContext tenantContext,
IHubContext<ShopHub> 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 });
}
@@ -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;
/// <c>CanManageJobs</c> policy (Managers and above within a company).
/// The platform-wide equivalent for SuperAdmins lives in
/// <see cref="PlatformNotificationsController"/>.
/// Uses <see cref="ApplicationDbContext"/> directly to enable LINQ projections
/// that avoid loading full message bodies into memory on the list page.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class NotificationLogsController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<NotificationLogsController> _logger;
public NotificationLogsController(ApplicationDbContext context, ILogger<NotificationLogsController> logger)
public NotificationLogsController(IUnitOfWork unitOfWork, ILogger<NotificationLogsController> logger)
{
_context = context;
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// 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.
/// <para>
/// The <c>pageSize</c> 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 (<c>SentAt DESC</c>) because operators
/// almost always want to see the latest delivery attempts first.
/// </para>
/// 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).
/// </summary>
// GET: /NotificationLogs
public async Task<IActionResult> 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<NotificationChannel>(channelFilter, out var ch) ? ch : null;
NotificationStatus? status = Enum.TryParse<NotificationStatus>(statusFilter, out var st) ? st : null;
NotificationType? type = Enum.TryParse<NotificationType>(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<NotificationChannel>(channelFilter, out var channel))
query = query.Where(n => n.Channel == channel);
if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse<NotificationStatus>(statusFilter, out var status))
query = query.Where(n => n.Status == status);
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<NotificationType>(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<NotificationLogDto>
{
@@ -144,22 +96,17 @@ public class NotificationLogsController : Controller
}
/// <summary>
/// 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.
/// </summary>
// GET: /NotificationLogs/Details/5
public async Task<IActionResult> 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();
@@ -20,6 +20,7 @@ namespace PowderCoating.Web.Controllers;
/// matches automatically on localhost, dev, staging, and production without any
/// environment-specific configuration.
/// </summary>
// 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") });
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>
/// Renders a paginated, filterable cross-company notification log view visible
/// only to SuperAdmins. Unlike the tenant-scoped
/// <see cref="NotificationLogsController.Index"/>, this action uses
/// <c>IgnoreQueryFilters()</c> to bypass the multi-tenancy global filter and
/// see logs from all companies simultaneously.
/// <para>
/// 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.
/// </para>
/// 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.
/// </summary>
public async Task<IActionResult> 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<NotificationType>(type, out var typeEnum))
query = query.Where(n => n.NotificationType == typeEnum);
all = all.Where(n => n.NotificationType == typeEnum);
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotificationStatus>(status, out var statusEnum))
query = query.Where(n => n.Status == statusEnum);
all = all.Where(n => n.Status == statusEnum);
if (!string.IsNullOrWhiteSpace(channel) && Enum.TryParse<NotificationChannel>(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
}
/// <summary>
/// Returns the full notification log entry for the given <paramref name="id"/>,
/// 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.
/// </summary>
public async Task<IActionResult> 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);
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<PurchaseOrdersController> _logger;
private readonly ApplicationDbContext _context;
private readonly IPdfService _pdfService;
public PurchaseOrdersController(
@@ -31,14 +29,12 @@ public class PurchaseOrdersController : Controller
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<PurchaseOrdersController> 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<PurchaseOrder>()
.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<List<PurchaseOrderListDto>>(items);
@@ -129,24 +80,12 @@ public class PurchaseOrdersController : Controller
PageSize = pageSize
};
// Stats
var allForStats = await _context.Set<PurchaseOrder>()
.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<PurchaseOrder>()
.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<PurchaseOrderDto>(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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<PurchaseOrder>()
.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<InventoryTransaction>().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<PurchaseOrder>()
.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<InventoryItem>()
.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<PurchaseOrder>()
.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
/// </summary>
private async Task PopulateCreateViewBagAsync(int companyId)
{
var vendors = await _context.Set<Vendor>()
.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<InventoryItem>()
.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
/// </summary>
private async Task PopulateVendorFilterDropdownAsync(int companyId)
{
var vendors = await _context.Set<Vendor>()
.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;
}
@@ -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<NotificationHub> _hub;
public QuoteApprovalController(
ApplicationDbContext db,
IUnitOfWork unitOfWork,
INotificationService notifications,
IInAppNotificationService inApp,
IStripeConnectService stripeConnect,
@@ -39,7 +38,7 @@ public class QuoteApprovalController : Controller
IConfiguration configuration,
IHubContext<NotificationHub> hub)
{
_db = db;
_unitOfWork = unitOfWork;
_notifications = notifications;
_inApp = inApp;
_stripeConnect = stripeConnect;
@@ -50,11 +49,8 @@ public class QuoteApprovalController : Controller
/// <summary>
/// Renders the main customer-facing approval page showing quote line items, totals, and
/// Approve/Decline buttons. The <c>[ActionName("View")]</c> attribute overrides the method name
/// so the route is <c>/quote-approval/{token}</c> without exposing the internal method name.
/// All token validation (expiry, already-acted) is centralised in <see cref="ValidateTokenAsync"/>.
/// Approve/Decline buttons.
/// </summary>
// GET /quote-approval/{token}
[HttpGet("{token}")]
[ActionName("View")]
public async Task<IActionResult> ShowApprovalPage(string token)
@@ -67,19 +63,15 @@ public class QuoteApprovalController : Controller
}
/// <summary>
/// Shows the contact-details confirmation step for prospect (non-customer) quotes. Prospects
/// have no <c>CustomerId</c> 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 <see cref="ApproveInternal"/> — 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.
/// </summary>
// GET /quote-approval/{token}/confirm-details
[HttpGet("{token}/confirm-details")]
public async Task<IActionResult> 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
}
/// <summary>
/// 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 <see cref="ApproveInternal"/> 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.
/// </summary>
// POST /quote-approval/{token}/confirm-details
[HttpPost("{token}/confirm-details")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
/// <summary>
/// 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 <see cref="ApproveInternal"/>. 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.
/// </summary>
// POST /quote-approval/{token}/approve
[HttpPost("{token}/approve")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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
}
/// <summary>
/// Core approval logic shared by both the customer path (<see cref="Approve"/>) and the prospect
/// path (<see cref="SubmitDetails"/>). Sets the quote status to the company's designated
/// <c>IsApprovedStatus</c> lookup entry, records <c>ApprovalTokenUsedAt</c> to prevent token
/// reuse, clears any prior decline reason (customers can re-approve after declining), and writes
/// a <c>QuoteChangeHistory</c> audit entry with <c>ChangedByUserId = null</c> 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 <c>Customer</c> row to attach a <c>Deposit</c> 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.
/// </summary>
private async Task<IActionResult> 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
}
/// <summary>
/// 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 (<c>DeclinedByIp</c>) for audit purposes. A SignalR event and
/// in-app notification are pushed to staff. The token is marked used (<c>ApprovalTokenUsedAt</c>)
/// 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.
/// </summary>
// POST /quote-approval/{token}/decline
[HttpPost("{token}/decline")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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
}
/// <summary>
/// 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
/// <see cref="ValidateTokenAsync"/> — 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 <c>action</c> query-string value ("approved" / "declined") is set by the
/// redirect from <see cref="ApproveInternal"/> or <see cref="Decline"/> 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.
/// </summary>
// GET /quote-approval/{token}/confirmation
[HttpGet("{token}/confirmation")]
public async Task<IActionResult> 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
// -----------------------------------------------------------------------
/// <summary>
/// Validates the approval token and returns the quote if it is still actionable, or an
/// <c>IActionResult</c> error view if not. Checks in order: token exists, not expired, not
/// already used (<c>ApprovalTokenUsedAt != null</c>), 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 <c>IgnoreQueryFilters</c> because the customer has no
/// tenant context. Loads <c>QuoteItems</c>, <c>QuoteStatus</c>, and <c>Customer</c> eagerly to
/// avoid N+1 queries in <see cref="BuildViewModelAsync"/>.
/// 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).
/// </summary>
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
}
/// <summary>
/// Builds the <c>QuoteApprovalViewModel</c> 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 <see cref="ValidateTokenAsync"/>. Soft-deleted line items are filtered
/// out so the customer only sees active items. Also maps all prospect contact fields so the
/// <c>ConfirmDetails</c> 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.
/// </summary>
private async Task<QuoteApprovalViewModel> 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<QuoteItem>())
.Where(i => !i.IsDeleted)
@@ -537,9 +476,7 @@ public class QuoteApprovalController : Controller
}
/// <summary>
/// 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.
/// </summary>
private static string GetCustomerName(Quote quote)
{
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<QuotesController> _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<ApplicationUser> userManager,
ILogger<QuotesController> 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).
/// </summary>
public async Task<IActionResult> Details(int? id)
public async Task<IActionResult> 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<QuoteDto>(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<List<QuoteItemDto>>(quoteItems);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(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<List<QuoteChangeHistoryDto>>(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<Deposit>()
.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<List<QuoteItemDto>>(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 <paramref name="customerId"/> is provided (e.g. when
/// navigating from the Customer Details page using the "New Quote" shortcut).
/// </summary>
public async Task<IActionResult> Create(int? customerId)
public async Task<IActionResult> 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
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateQuoteDto dto)
public async Task<IActionResult> 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
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ConvertToJob(int id)
public async Task<IActionResult> 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<List<QuoteItemDto>>(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
/// </summary>
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<List<QuoteItemDto>>(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<IActionResult> 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<CompanyPreferences?> 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
@@ -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;
/// </summary>
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
/// <summary>
/// Renders the public changelog — shows only published release notes ordered
@@ -39,54 +37,39 @@ public class ReleaseNotesController : Controller
[Authorize]
public async Task<IActionResult> 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 ───────────────────────────────────────────────────
/// <summary>
/// Returns the SuperAdmin management list of all release notes (published and
/// draft alike), ordered newest-first. Unlike <see cref="Index"/> there is no
/// <c>IsPublished</c> 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.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> 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);
}
/// <summary>
/// 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.
/// </summary>
[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" });
}
/// <summary>
/// Persists a new release note and captures the creating SuperAdmin's identity
/// (<c>CreatedByUserId</c> / <c>CreatedByUserName</c>) for audit purposes.
/// New notes start unpublished by default unless the form explicitly sets
/// <c>IsPublished = true</c>, 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.
/// </summary>
[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));
}
/// <summary>
/// Returns the Edit form loaded from the database by primary key.
/// </summary>
/// <summary>Returns the Edit form loaded from the database by primary key.</summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> 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);
}
/// <summary>
/// Applies the edited values to the tracked entity. Uses explicit field
/// mapping (rather than <c>_db.Entry(model).State = Modified</c>) to prevent
/// over-posting attacks and to ensure audit fields like <c>CreatedAt</c> and
/// <c>CreatedByUserId</c> 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.
/// </summary>
[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));
}
/// <summary>
/// Toggles the published state of a release note. Publishing makes the note
/// immediately visible to all authenticated users via <see cref="Index"/>;
/// 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> 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
}
/// <summary>
/// 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 <see cref="TogglePublish"/> to hide a note without permanent removal.
/// Permanently (hard) deletes a release note. Release notes are platform metadata and do not use soft delete.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> 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));
}
/// <summary>
/// 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.
/// </summary>
private Task NotifyAllTenantsAsync(ReleaseNote note)
{
@@ -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<ReportsController> _logger;
private readonly ApplicationDbContext _context;
private readonly IFinancialReportService _financialReports;
private readonly IOperationalReportService _operationalReports;
private readonly IPdfService _pdfService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IAccountingAiService _accountingAi;
private readonly IAiUsageLogger _usageLogger;
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, ApplicationDbContext context, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> 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<IActionResult> 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<int, decimal>();
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<FinancialReportLine>();
var expenseLines = new List<FinancialReportLine>();
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<IActionResult> 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<IActionResult> 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<ArAgingCustomerDto>();
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<IActionResult> 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<IActionResult> 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<int, decimal>();
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<FinancialReportLine>(); var expenseLines = new List<FinancialReportLine>();
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<IActionResult> 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<IActionResult> 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<ArAgingCustomerDto>();
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<IActionResult> 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<AgingBucketItem> { new() { Label = "Current (030 days)" }, new() { Label = "3160 days" }, new() { Label = "6190 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<string, (string DisplayName, List<double> 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";
@@ -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.
/// </summary>
// 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
{
@@ -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<ApplicationUser> _userManager;
private readonly ApplicationDbContext _context;
private readonly ISeedDataService _seedDataService;
private readonly ILogger<SetupWizardController> _logger;
@@ -28,14 +26,12 @@ public class SetupWizardController : Controller
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ApplicationDbContext context,
ISeedDataService seedDataService,
ILogger<SetupWizardController> 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<CompanyPreferences>().Add(company.Preferences);
await _unitOfWork.CompanyPreferences.AddAsync(company.Preferences);
await _unitOfWork.CompleteAsync();
}
if (company.OperatingCosts == null)
{
company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId };
_context.Set<CompanyOperatingCosts>().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);
}
/// <summary>
/// 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., <c>tierName</c> not <c>TierName</c>), unlike the oven step
/// which uses PascalCase — a discrepancy inherited from different JS widget implementations.
/// </summary>
private async Task<IActionResult> 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);
}
/// <summary>
/// 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.
/// </summary>
private async Task<IActionResult> 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 ───────────────────────────────────────────────────────────
/// <summary>
@@ -679,138 +603,15 @@ public class SetupWizardController : Controller
return RedirectToStep(5);
}
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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);
}
/// <summary>
/// Persists pricing tiers from Step 8, using the same upsert-and-soft-delete pattern as
/// <see cref="PostStep4"/>: existing tiers updated in place, new ones inserted, removed ones
/// soft-deleted. Tiers with a blank <c>TierName</c> 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.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep8(WizardPricingTiersStepDto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
var companyId = GetCompanyId();
if (!string.IsNullOrWhiteSpace(model.TiersJson))
{
try
{
var tiers = JsonSerializer.Deserialize<List<WizardPricingTierDto>>(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<IActionResult> 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);
}
/// <summary>
/// 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 <c>CompanyRole</c> 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 <c>SetupWizardCompleted = true</c>
/// here hides the wizard prompt from the dashboard going forward.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PostStep10(WizardStep9Dto model)
{
var (_, prefs, _) = await LoadCompanyDataAsync();
var companyId = GetCompanyId();
if (!string.IsNullOrWhiteSpace(model.MembersJson))
{
try
{
var members = JsonSerializer.Deserialize<List<WizardTeamMemberDto>>(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();
}
}
@@ -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<SmsConsentAuditController> _logger;
public SmsConsentAuditController(ApplicationDbContext context, ILogger<SmsConsentAuditController> logger)
public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger<SmsConsentAuditController> 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)");
@@ -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.
/// </summary>
// 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
{
@@ -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.
/// </summary>
// 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
{
@@ -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<UnsubscribeController> _logger;
public UnsubscribeController(ApplicationDbContext context, ILogger<UnsubscribeController> logger)
public UnsubscribeController(IUnitOfWork unitOfWork, ILogger<UnsubscribeController> 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);
}
@@ -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.
/// </summary>
// 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
{
@@ -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
{
@@ -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<ApplicationUser> _userManager;
private readonly ILogger<VendorsController> _logger;
private readonly ApplicationDbContext _context;
public VendorsController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<VendorsController> logger,
ApplicationDbContext context)
ILogger<VendorsController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_context = context;
}
/// <summary>
@@ -163,7 +159,7 @@ public class VendorsController : Controller
var vendorDto = _mapper.Map<VendorDto>(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
/// </summary>
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;

Some files were not shown because too many files have changed in this diff Show More