Compare commits

...

6 Commits

Author SHA1 Message Date
spouliot 6993c2c462 Fix invoice detail crash after first credit memo or refund is applied
AutoMapper 12+ throws AutoMapperMappingException when mapping a non-empty
collection for which no element type map is registered. Invoice.CreditApplications
and Invoice.Refunds had no CreateMap entries, so the invoice Details view worked
fine until the first credit or refund existed — at that point AutoMapper tried
to map the element type and threw, causing the catch block to redirect to the
invoice list with a generic "failed to load" error.

Fix: mark CreditApplications and Refunds as Ignore() in the Invoice->InvoiceDto
AutoMapper profile. Both collections are already built manually in
BuildInvoiceDtoAsync, matching the existing GiftCertificateRedemptions pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:17:38 -04:00
spouliot 1cb7a8ca4a Phases 3 & 4: Complete data access architecture migration
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:17:29 -04:00
spouliot 90bc0d965f Phase 2: Eliminate ApplicationDbContext from domain controllers
Migrated InvoicesController, QuotesController, JobsController, BillsController,
PurchaseOrdersController, and CustomersController to route all data access
through IUnitOfWork typed/generic repositories instead of injecting
ApplicationDbContext directly.

New typed repositories added: IJobRepository (GetScheduledJobsForDateAsync,
GetActiveJobsForMobileAsync, LoadForCostingAsync), INotificationLogRepository
(GetLatestForJobAsync, GetAllForJobAsync), IQuoteRepository (GetItemsWithCoatsAsync
with CatalogItem eager load + AsNoTracking), and IJobRepository.GetOrphanedConversionJobAsync.

All EF complex include chains relocated into repository methods; controllers now
call named query methods rather than composing raw IQueryable chains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 21:20:39 -04:00
spouliot 80b0e547cc Phase 1: Introduce typed repository interfaces and report service stubs
Six IUnitOfWork properties upgraded from generic IRepository<T> to domain-specific
typed interfaces (IJobRepository, IQuoteRepository, IInvoiceRepository,
ICustomerRepository, IBillRepository, IPurchaseOrderRepository). Each backed by a
concrete typed repository that encapsulates complex include chains previously
inlined in controllers.

Also adds IFinancialReportService and IOperationalReportService stub implementations
(NotImplementedException placeholders) to Application.Interfaces and Infrastructure.Services,
registered in Program.cs. These are the migration targets for ReportsController's
aggregate query methods in Phase 2.

No controller behaviour changed in this commit — all callers still compile because
typed interfaces extend IRepository<T>.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:54:10 -04:00
spouliot 92dc3ebd08 Add data access architecture spec and enforce rules in CLAUDE.md
Defines the target architecture for eliminating direct ApplicationDbContext
injection from controllers. Documents the three-tier model (generic repo,
typed domain repos, read services), the 6 typed repository interfaces to
build, the 2 reporting service interfaces to build, permanent exceptions,
and the 4-phase migration roadmap with per-controller checklist.

CLAUDE.md updated with the hard rule and tier quick-reference so every
session and every team member sees the constraint immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:35:16 -04:00
spouliot 5631d1d57a Add WarningPermanent toast type and upgrade invoice failure notifications
Email delivery failures and PDF generation errors now show a permanent
warning/error toast that requires manual dismissal, so users cannot
accidentally miss critical action-blocking feedback.

- ToastHelper: WarningPermanent TempData key + Warning/WarningPermanent
  extension methods on both ITempDataDictionary and Controller
- SetNotificationResultToast: NotificationStatus.Failed now uses
  ToastWarningPermanent (previously auto-dismissed in 5 s)
- InvoicesController.Send: TempData["Warning"] → TempData["WarningPermanent"]
  when PDF generation or email dispatch fails
- InvoicesController.DownloadPdf: TempData["Error"] → TempData["ErrorPermanent"]
  with the actual exception message so root cause is visible
- _Layout.cshtml: WarningPermanent hidden div
- toast-notifications.js: WarningPermanent handler (timeOut: 0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:02:16 -04:00
94 changed files with 10890 additions and 3171 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
@@ -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>()
@@ -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,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();
}
}
@@ -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,12 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Dashboard;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus
using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus, EquipmentStatus, PaymentMethod
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Shared.Constants;
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
@@ -17,7 +16,9 @@ public class DashboardController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<DashboardController> _logger;
private readonly ApplicationDbContext _context;
private readonly IDashboardReadService _dashboardRead;
private readonly ITenantContext _tenantContext;
private readonly ICompanyConfigHealthService _configHealth;
private static readonly string[] CompletedStatusCodes =
[
@@ -39,14 +40,16 @@ public class DashboardController : Controller
"QUALITY_CHECK"
];
private readonly ITenantContext _tenantContext;
private readonly ICompanyConfigHealthService _configHealth;
public DashboardController(IUnitOfWork unitOfWork, ILogger<DashboardController> logger, ApplicationDbContext context, ITenantContext tenantContext, ICompanyConfigHealthService configHealth)
public DashboardController(
IUnitOfWork unitOfWork,
ILogger<DashboardController> logger,
IDashboardReadService dashboardRead,
ITenantContext tenantContext,
ICompanyConfigHealthService configHealth)
{
_unitOfWork = unitOfWork;
_logger = logger;
_context = context;
_dashboardRead = dashboardRead;
_tenantContext = tenantContext;
_configHealth = configHealth;
}
@@ -66,23 +69,14 @@ public class DashboardController : Controller
try
{
var today = DateTime.Today;
var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
var lookAheadDate = today.AddDays(7); // Changed to 7 days for expiring quotes
var lookAheadDate = today.AddDays(7);
// Active jobs — filter completed/cancelled statuses at database level
var activeJobs = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.AssignedUser)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
.ToListAsync();
var data = await _dashboardRead.GetIndexDataAsync(today);
var tomorrow = today.AddDays(1);
// Today's Jobs
var todaysJobsFiltered = activeJobs
// ---------------------------------------------------------------
// Job panels — in-memory split of the pre-fetched activeJobs list
// ---------------------------------------------------------------
var todaysJobsFiltered = data.ActiveJobs
.Where(j => (j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today) ||
(j.DueDate.HasValue && j.DueDate.Value.Date == today));
var todaysJobsCount = todaysJobsFiltered.Count();
@@ -93,8 +87,7 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
// Overdue Jobs
var overdueJobsFiltered = activeJobs
var overdueJobsFiltered = data.ActiveJobs
.Where(j => j.DueDate.HasValue && j.DueDate.Value.Date < today);
var overdueJobsCount = overdueJobsFiltered.Count();
var overdueJobs = overdueJobsFiltered
@@ -104,8 +97,7 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
// In-Progress Jobs
var inProgressJobs = activeJobs
var inProgressJobs = data.ActiveJobs
.Where(j => InProgressStatusCodes.Contains(j.JobStatus.StatusCode))
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
@@ -113,26 +105,11 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
// Monthly Revenue — aggregate at database level (no need to load all jobs)
var monthlyRevenue = await _context.Jobs
.Include(j => j.JobStatus)
.Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.UpdatedAt >= startOfMonth
&& j.UpdatedAt <= endOfMonth)
.SumAsync(j => j.FinalPrice);
// Today's Appointments — filter at database level
var todaysAppointmentsRaw = await _context.Appointments
.Include(a => a.Customer)
.Include(a => a.AppointmentType)
.Include(a => a.AppointmentStatus)
.Include(a => a.AssignedUser)
.Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow
&& a.AppointmentStatus.StatusCode != "CANCELLED")
.OrderBy(a => a.ScheduledStartTime)
.ToListAsync();
var todaysAppointmentsCount = todaysAppointmentsRaw.Count;
var todaysAppointments = todaysAppointmentsRaw
// ---------------------------------------------------------------
// Appointments
// ---------------------------------------------------------------
var todaysAppointmentsCount = data.TodaysAppointments.Count;
var todaysAppointments = data.TodaysAppointments
.Take(10)
.Select(a => new DashboardAppointmentDto
{
@@ -150,9 +127,11 @@ public class DashboardController : Controller
AssignedWorkerName = a.AssignedUser?.FullName
}).ToList();
// ---------------------------------------------------------------
// Low stock items
// ---------------------------------------------------------------
var lowStockAll = await _unitOfWork.InventoryItems.FindAsync(
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
var lowStockCount = lowStockAll.Count();
var lowStockItems = lowStockAll
.OrderBy(i => i.QuantityOnHand)
@@ -168,21 +147,10 @@ public class DashboardController : Controller
UnitOfMeasure = i.UnitOfMeasure
}).ToList();
// Maintenance records — filter to pending/overdue at database level
var upcomingMaintenance = await _context.MaintenanceRecords
.Include(m => m.Equipment)
.Include(m => m.AssignedUser)
.Where(m => (m.Status == MaintenanceStatus.Scheduled
|| m.Status == MaintenanceStatus.InProgress
|| m.Status == MaintenanceStatus.Overdue)
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate))
.OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
.ThenByDescending(m => m.Priority)
.ThenBy(m => m.ScheduledDate)
.Take(10)
.ToListAsync();
var upcomingMaintenanceDtos = upcomingMaintenance
// ---------------------------------------------------------------
// Maintenance
// ---------------------------------------------------------------
var upcomingMaintenanceDtos = data.UpcomingMaintenance
.Select(m => new DashboardMaintenanceDto
{
Id = m.Id,
@@ -195,14 +163,10 @@ public class DashboardController : Controller
AssignedWorkerName = m.AssignedUser?.FullName
}).ToList();
// Pending Quotes — filter to SENT status at database level
var pendingQuotesData = await _context.Quotes
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.QuoteStatus.StatusCode == "SENT")
.ToListAsync();
var pendingQuotes = pendingQuotesData
// ---------------------------------------------------------------
// Quotes
// ---------------------------------------------------------------
var pendingQuotes = data.PendingQuotes
.OrderBy(q => q.ExpirationDate)
.Take(10)
.Select(q => new DashboardQuoteDto
@@ -221,10 +185,9 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
var pendingQuoteValue = pendingQuotesData.Sum(q => q.Total);
var pendingQuoteValue = data.PendingQuotes.Sum(q => q.Total);
// Expiring Quotes (next 7 days) - filter at database level
var expiringQuotes = pendingQuotesData
var expiringQuotes = data.PendingQuotes
.Where(q => q.ExpirationDate.HasValue
&& q.ExpirationDate.Value.Date >= today
&& q.ExpirationDate.Value.Date <= lookAheadDate)
@@ -246,33 +209,17 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
// Active Customers
// ---------------------------------------------------------------
// Active customers
// ---------------------------------------------------------------
var activeCustomersCount = await _unitOfWork.Customers.CountAsync(c => c.IsActive);
// ---------------------------------------------------------------
// Financial data — Invoices & Payments
// Invoices & AR aging
// ---------------------------------------------------------------
var openStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
var outstandingAr = data.OpenInvoices.Sum(i => i.BalanceDue);
// Open invoices only — filter at database level
var openInvoices = await _context.Invoices
.Include(i => i.Customer)
.Where(i => openStatuses.Contains(i.Status))
.ToListAsync();
var outstandingAr = openInvoices.Sum(i => i.BalanceDue);
// Invoiced this month — aggregate at database level
var invoicedThisMonth = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.WrittenOff
&& i.InvoiceDate >= startOfMonth
&& i.InvoiceDate <= endOfMonth)
.SumAsync(i => i.Total);
// Overdue invoices: open and past due date
var overdueInvoicesList = openInvoices
var overdueInvoicesList = data.OpenInvoices
.Where(i => i.DueDate.HasValue && i.DueDate.Value.Date < today)
.OrderBy(i => i.DueDate)
.ToList();
@@ -295,9 +242,9 @@ public class DashboardController : Controller
})
.ToList();
// AR Aging bucket open invoices by days past due
// AR Aging buckets
decimal agingCurrent = 0, aging1To30 = 0, aging31To60 = 0, aging61To90 = 0, agingOver90 = 0;
foreach (var inv in openInvoices)
foreach (var inv in data.OpenInvoices)
{
if (!inv.DueDate.HasValue || inv.DueDate.Value.Date >= today)
{
@@ -313,17 +260,10 @@ public class DashboardController : Controller
}
}
// Payments this month — aggregate at database level
var collectedThisMonth = await _context.Payments
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
.SumAsync(p => p.Amount);
// Recent payments — load only the 6 most recent
var recentPayments = (await _context.Payments
.Include(p => p.Invoice).ThenInclude(i => i!.Customer)
.OrderByDescending(p => p.PaymentDate)
.Take(6)
.ToListAsync())
// ---------------------------------------------------------------
// Payments
// ---------------------------------------------------------------
var recentPayments = data.RecentPayments
.Select(p => new DashboardPaymentDto
{
Id = p.Id,
@@ -335,44 +275,39 @@ public class DashboardController : Controller
PaymentDate = p.PaymentDate,
PaymentMethodDisplay = p.PaymentMethod switch
{
PowderCoating.Core.Enums.PaymentMethod.Cash => "Cash",
PowderCoating.Core.Enums.PaymentMethod.Check => "Check",
PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard => "Card",
PowderCoating.Core.Enums.PaymentMethod.BankTransferACH => "ACH",
PowderCoating.Core.Enums.PaymentMethod.DigitalPayment => "Digital",
PaymentMethod.Cash => "Cash",
PaymentMethod.Check => "Check",
PaymentMethod.CreditDebitCard => "Card",
PaymentMethod.BankTransferACH => "ACH",
PaymentMethod.DigitalPayment => "Digital",
_ => "Other"
}
})
.ToList();
// Equipment Alerts - filter at database level
// ---------------------------------------------------------------
// Equipment alerts
// ---------------------------------------------------------------
var equipmentAlerts = (await _unitOfWork.Equipment.FindAsync(
e => e.Status == Core.Enums.EquipmentStatus.NeedsMaintenance ||
e.Status == Core.Enums.EquipmentStatus.OutOfService))
.OrderByDescending(e => e.Status == Core.Enums.EquipmentStatus.OutOfService ? 1 : 0)
e => e.Status == EquipmentStatus.NeedsMaintenance ||
e.Status == EquipmentStatus.OutOfService))
.OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0)
.Take(5)
.Select(e => new DashboardEquipmentAlertDto
{
Id = e.Id,
EquipmentName = e.EquipmentName,
EquipmentType = e.EquipmentType,
Issue = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
Severity = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Critical" : "Warning",
Issue = e.Status == EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
Severity = e.Status == EquipmentStatus.OutOfService ? "Critical" : "Warning",
LastMaintenanceDate = e.LastMaintenanceDate,
NextMaintenanceDue = null // Equipment doesn't track next maintenance due date
NextMaintenanceDue = null
}).ToList();
// Recent Activity (last 10 quotes or jobs created in last 30 days)
var last30Days = today.AddDays(-30);
// Recent quotes — filter to last 30 days at database level
var recentQuotes = (await _context.Quotes
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.CreatedAt >= last30Days)
.OrderByDescending(q => q.CreatedAt)
.Take(5)
.ToListAsync())
// ---------------------------------------------------------------
// Recent activity
// ---------------------------------------------------------------
var recentQuoteDtos = data.RecentQuotes
.Select(q => new DashboardRecentActivityDto
{
Id = q.Id,
@@ -390,14 +325,7 @@ public class DashboardController : Controller
Amount = q.Total
});
// Recent jobs — filter to last 30 days at database level
var recentJobs = (await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Where(j => j.CreatedAt >= last30Days)
.OrderByDescending(j => j.CreatedAt)
.Take(5)
.ToListAsync())
var recentJobDtos = data.RecentJobs
.Select(j => new DashboardRecentActivityDto
{
Id = j.Id,
@@ -413,33 +341,15 @@ public class DashboardController : Controller
Amount = j.FinalPrice
});
var recentActivity = recentQuotes.Concat(recentJobs)
var recentActivity = recentQuoteDtos.Concat(recentJobDtos)
.OrderByDescending(a => a.ActivityDate)
.Take(10)
.ToList();
// === POWDER ORDERS NEEDED ===
var jobsNeedingPowder = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
!c.PowderOrdered &&
c.PowderToOrder > 0 &&
(c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))))
.ToListAsync();
// Flatten to individual coat lines that need ordering (with vendor info for grouping)
var powderFlat = jobsNeedingPowder
// ---------------------------------------------------------------
// Powder orders needed
// ---------------------------------------------------------------
var powderFlat = data.JobsNeedingPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && !c.PowderOrdered && c.PowderToOrder > 0
@@ -500,26 +410,10 @@ public class DashboardController : Controller
.OrderBy(g => g.VendorName)
.ToList();
// === POWDER ORDERS PLACED (ordered, awaiting receipt) ===
var jobsWithOrderedPowder = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
c.PowderOrdered &&
!c.PowderReceived)))
.ToListAsync();
var placedFlat = jobsWithOrderedPowder
// ---------------------------------------------------------------
// Powder orders placed
// ---------------------------------------------------------------
var placedFlat = data.JobsWithOrderedPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && c.PowderOrdered && !c.PowderReceived)
@@ -584,16 +478,10 @@ public class DashboardController : Controller
.OrderBy(g => g.VendorName)
.ToList();
// === BILLS DUE ===
var billsDueRaw = await _context.Bills
.Include(b => b.Vendor)
.Where(b => !b.IsDeleted &&
(b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) &&
b.Total > b.AmountPaid)
.OrderBy(b => b.DueDate)
.Take(15)
.ToListAsync();
var billsDue = billsDueRaw.Select(b => new DashboardBillDto
// ---------------------------------------------------------------
// Bills due
// ---------------------------------------------------------------
var billsDue = data.BillsDue.Select(b => new DashboardBillDto
{
Id = b.Id,
BillNumber = b.BillNumber,
@@ -608,21 +496,21 @@ public class DashboardController : Controller
var vm = new DashboardViewModel
{
// Counts
ActiveJobsCount = activeJobs.Count(),
ActiveJobsCount = data.ActiveJobs.Count,
TodaysJobsCount = todaysJobsCount,
OverdueJobsCount = overdueJobsCount,
TodaysAppointmentsCount = todaysAppointmentsCount,
LowStockCount = lowStockCount,
PendingMaintenanceCount = upcomingMaintenance.Count,
PendingQuotesCount = pendingQuotesData.Count(),
PendingMaintenanceCount = data.UpcomingMaintenance.Count,
PendingQuotesCount = data.PendingQuotes.Count,
PendingQuoteValue = pendingQuoteValue,
MonthlyRevenue = monthlyRevenue,
MonthlyRevenue = data.MonthlyRevenue,
ActiveCustomersCount = activeCustomersCount,
// Financial KPIs
OutstandingAr = outstandingAr,
CollectedThisMonth = collectedThisMonth,
InvoicedThisMonth = invoicedThisMonth,
CollectedThisMonth = data.CollectedThisMonth,
InvoicedThisMonth = data.InvoicedThisMonth,
OverdueInvoicesCount = overdueInvoicesCount,
OverdueInvoicesAmount = overdueInvoicesAmount,
AgingCurrent = agingCurrent,
@@ -654,7 +542,9 @@ public class DashboardController : Controller
PowderOrdersNeeded = powderOrderGroups,
PowderOrdersNeededCount = powderFlat.Count,
PowderOrdersPlaced = powderPlacedGroups,
PowderOrdersPlacedCount = placedFlat.Count
PowderOrdersPlacedCount = placedFlat.Count,
TipOfTheDay = data.TipOfTheDay
};
// Dropdowns for the "Add Custom Powder to Inventory" modal
@@ -671,13 +561,6 @@ public class DashboardController : Controller
ViewBag.InventoryCategories = inventoryCategories;
ViewBag.VendorList = vendors;
// Random tip of the day
var tips = await _context.DashboardTips
.Where(t => t.IsActive)
.ToListAsync();
if (tips.Count > 0)
vm.TipOfTheDay = tips[Random.Shared.Next(tips.Count)].TipText;
// Config health check — surface setup gaps to company admins
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
if (currentCompanyId.HasValue)
@@ -705,11 +588,7 @@ public class DashboardController : Controller
{
try
{
var coat = await _context.JobItemCoats
.Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer)
.Include(c => c.Vendor)
.Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor)
.FirstOrDefaultAsync(c => c.Id == coatId);
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat not found." });
@@ -722,7 +601,7 @@ public class DashboardController : Controller
coat.PowderOrdered = true;
coat.PowderOrderedAt = DateTime.UtcNow;
coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
var job = coat.JobItem?.Job;
@@ -761,9 +640,9 @@ public class DashboardController : Controller
/// Records receipt of a powder shipment against an existing powder order. Sets
/// <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 +650,8 @@ public class DashboardController : Controller
{
try
{
var coat = await _context.JobItemCoats
.Include(c => c.InventoryItem)
.FirstOrDefaultAsync(c => c.Id == coatId);
// Load coat with inventory item for the stock update
var coat = await _unitOfWork.JobItemCoats.LoadWithInventoryAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
@@ -781,29 +659,25 @@ public class DashboardController : Controller
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
// Verify ownership — JobItemCoat has no CompanyId, check via parent job
// (We need the job for company check; load it if not already included)
// Verify ownership — JobItemCoat has no CompanyId, check via parent job.
// If JobItem/Job wasn't populated by the initial load, bring in the chain via a second
// query; EF Core identity-map fixup will propagate the navigation to the tracked coat.
var coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
if (coatJobCompanyId == null)
{
// Reload with parent chain if not included
var coatWithJob = await _context.JobItemCoats
.Include(c => c.JobItem).ThenInclude(i => i.Job)
.FirstOrDefaultAsync(c => c.Id == coatId);
coatJobCompanyId = coatWithJob?.JobItem?.Job?.CompanyId;
await _unitOfWork.JobItemCoats.LoadWithJobChainAsync(coatId);
coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
}
if (!_tenantContext.IsSuperAdmin() && coatJobCompanyId != _tenantContext.GetCurrentCompanyId())
return Json(new { success = false, message = "Access denied." });
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
// Mark coat as received
coat.PowderReceived = true;
coat.PowderReceivedAt = DateTime.UtcNow;
coat.PowderReceivedByUserId = userId;
coat.PowderReceivedLbs = lbsReceived;
// Update inventory if this coat is linked to an inventory item
if (coat.InventoryItemId.HasValue && coat.InventoryItem != null)
{
var item = coat.InventoryItem;
@@ -813,26 +687,24 @@ public class DashboardController : Controller
if (coat.PowderCostPerLb.HasValue)
item.LastPurchasePrice = coat.PowderCostPerLb.Value;
// Record purchase transaction
var transaction = new PowderCoating.Core.Entities.InventoryTransaction
var transaction = new InventoryTransaction
{
CompanyId = item.CompanyId,
InventoryItemId = item.Id,
TransactionType = PowderCoating.Core.Enums.InventoryTransactionType.Purchase,
TransactionType = InventoryTransactionType.Purchase,
Quantity = lbsReceived,
UnitCost = coat.PowderCostPerLb ?? item.UnitCost,
TotalCost = lbsReceived * (coat.PowderCostPerLb ?? item.UnitCost),
TransactionDate = DateTime.UtcNow,
Reference = coat.JobItem != null ? null : null, // loaded below if needed
Notes = $"Received {lbsReceived:N2} lbs for job order",
BalanceAfter = previousBalance + lbsReceived,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_context.Set<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 +736,7 @@ public class DashboardController : Controller
{
try
{
var coat = await _context.JobItemCoats.FindAsync(coatId);
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
@@ -877,16 +749,13 @@ public class DashboardController : Controller
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
// Resolve company id from tenant context
var companyId = (await _unitOfWork.InventoryItems.GetAllAsync()).FirstOrDefault()?.CompanyId ?? 0;
// More reliably get CompanyId from the job chain
var jobItem = await _context.JobItems.Include(i => i.Job).FirstOrDefaultAsync(i => i.Coats.Any(c => c.Id == coatId));
if (jobItem?.Job != null)
companyId = jobItem.Job.CompanyId;
// Resolve company id from the job chain; fall back to tenant context
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Check SKU uniqueness
var existingSku = await _context.InventoryItems.AnyAsync(i => i.SKU == sku.Trim());
if (existingSku)
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
// Determine category display name for legacy field
@@ -925,10 +794,10 @@ public class DashboardController : Controller
UpdatedAt = DateTime.UtcNow,
};
_context.InventoryItems.Add(inventoryItem);
await _context.SaveChangesAsync();
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
// Record opening stock transaction
// Opening stock transaction
var transaction = new InventoryTransaction
{
CompanyId = companyId,
@@ -943,7 +812,7 @@ public class DashboardController : Controller
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_context.Set<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 +822,12 @@ public class DashboardController : Controller
coat.PowderReceivedLbs = lbsReceived;
coat.InventoryItemId = inventoryItem.Id;
// Scan for other active job coats using the same custom powder and link them
var candidateCoats = await _context.JobItemCoats
.Include(c => c.JobItem)
.Where(c => !c.IsDeleted
&& c.Id != coatId
&& c.InventoryItemId == null
&& c.JobItem.CompanyId == companyId)
.ToListAsync();
// Scan for sibling coats with the same custom powder and link them to the new item
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId);
int linkedCount = 0;
foreach (var other in candidateCoats)
{
// Match by color code first (most specific), then fall back to color name
bool colorMatch = !string.IsNullOrWhiteSpace(colorCode)
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
: !string.IsNullOrWhiteSpace(colorName) &&
@@ -973,7 +835,6 @@ public class DashboardController : Controller
if (!colorMatch) continue;
// If both coats have a vendor set, they must agree
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
continue;
@@ -985,7 +846,7 @@ public class DashboardController : Controller
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
linkedCount, inventoryItem.Id, inventoryItem.SKU);
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
}
@@ -1014,13 +875,10 @@ public class DashboardController : Controller
var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true);
var companies = allCompanies.Where(c => !c.IsDeleted).ToList();
var totalUsers = await _context.Users
.Where(u => u.CompanyId > 0)
.CountAsync();
var totalUsers = await _dashboardRead.GetTotalUserCountAsync();
var graceCutoff = today.AddDays(-AppConstants.SubscriptionConstants.GracePeriodDays);
// Load plan configs from DB so plan display names and distribution are DB-driven
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
@@ -1029,7 +887,6 @@ public class DashboardController : Controller
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
// Companies needing attention: expired (past grace) or in grace period
var companyAlerts = companies
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
.OrderBy(c => c.SubscriptionEndDate)
@@ -1066,7 +923,6 @@ public class DashboardController : Controller
})
.ToList();
// Build plan distribution from DB config (sorted by SortOrder)
var planDistribution = planConfigs.ToDictionary(
c => c.Plan,
c => (c.DisplayName, companies.Count(comp => comp.SubscriptionPlan == c.Plan)));
@@ -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)
@@ -12,6 +12,7 @@ using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
// Intentional exception: cross-tenant fan-out querying ASP.NET Identity Users table with company joins; Identity entities live outside IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class EmailBroadcastController : Controller
{
@@ -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))
@@ -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;
@@ -256,8 +251,7 @@ 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
@@ -298,12 +292,10 @@ public class InvoicesController : Controller
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 +313,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 +333,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 +342,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
@@ -494,9 +482,7 @@ public class InvoicesController : Controller
// 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.");
@@ -591,11 +577,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)
{
@@ -908,16 +893,12 @@ public class InvoicesController : Controller
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["Warning"] = "The invoice is marked as sent, but PDF generation or the customer email failed. Check the notification logs or your email configuration.";
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)
@@ -1043,11 +1024,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
@@ -1302,8 +1279,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 });
}
}
@@ -1325,9 +1302,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 });
@@ -1386,11 +1361,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}" });
@@ -1420,16 +1391,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);
}
@@ -1474,32 +1439,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);
@@ -1529,36 +1486,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
@@ -1737,11 +1669,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)
@@ -1764,26 +1695,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;
}
@@ -1802,8 +1725,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
@@ -1812,12 +1734,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 });
}
@@ -1845,15 +1767,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;
}
@@ -2371,16 +2291,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);
@@ -2405,8 +2322,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)
@@ -2417,8 +2333,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/");
@@ -2452,27 +2368,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
{
@@ -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;
@@ -239,17 +235,7 @@ 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 now = DateTime.UtcNow.Date;
@@ -296,15 +282,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 +298,7 @@ public class JobsController : Controller
job.UpdatedAt = DateTime.UtcNow;
job.UpdatedBy = User.Identity?.Name;
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "StatusChanged",
$"Status → {newStatus.DisplayName}");
@@ -354,49 +338,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 +358,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 +376,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 +436,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;
@@ -650,46 +583,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);
@@ -943,16 +842,23 @@ public class JobsController : Controller
// 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 +868,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 +899,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
})
})
@@ -1071,14 +977,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 +1060,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,
@@ -1212,26 +1118,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 +1144,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 +1181,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 +1231,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 +1240,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 +1310,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 +1589,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 +1608,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 +1646,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 +1834,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 +1866,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 +1973,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 +2103,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;
@@ -2330,9 +2180,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 +2190,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 +2207,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 +2320,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 +2385,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 +2574,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 +2678,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 +2829,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 +2838,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 +2923,7 @@ public class JobsController : Controller
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _context.JobItemPrepServices.AddAsync(ps);
await _unitOfWork.JobItemPrepServices.AddAsync(ps);
}
}
}
@@ -3136,8 +2965,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 +2974,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 +3300,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 +3392,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 +3562,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();
@@ -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
{
@@ -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();
@@ -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
@@ -564,16 +519,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 +578,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
{
@@ -1186,11 +1130,7 @@ 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);
}
@@ -1231,13 +1171,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)
@@ -2230,15 +2164,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);
@@ -2351,25 +2283,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 +2321,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 +2378,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 +2431,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);
}
@@ -2766,9 +2675,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);
@@ -2841,13 +2749,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);
@@ -2864,10 +2766,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
{
@@ -2895,23 +2795,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)
@@ -3017,15 +2908,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.
@@ -3166,9 +3049,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()
@@ -3178,7 +3060,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,
@@ -3186,7 +3068,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);
}
@@ -3200,10 +3082,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)
{
@@ -3223,7 +3103,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,
@@ -3239,7 +3119,7 @@ public class QuotesController : Controller
});
}
}
await _context.SaveChangesAsync();
await _unitOfWork.SaveChangesAsync();
}
catch (Exception photoEx)
{
@@ -3263,23 +3143,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)
@@ -3352,11 +3223,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}" });
@@ -3383,16 +3250,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);
}
@@ -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();
}
@@ -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;
+22 -1
View File
@@ -31,6 +31,9 @@ namespace PowderCoating.Web.Helpers
/// <summary>TempData key read by the layout to render a yellow warning toast.</summary>
private const string WarningKey = "Warning";
/// <summary>TempData key read by the layout to render a yellow warning toast that does not auto-dismiss.</summary>
private const string WarningPermanentKey = "WarningPermanent";
/// <summary>TempData key read by the layout to render a blue info toast.</summary>
private const string InfoKey = "Info";
@@ -67,6 +70,15 @@ namespace PowderCoating.Web.Helpers
tempData[WarningKey] = message;
}
/// <summary>
/// Stores a warning (yellow) toast that requires manual dismissal (no auto-timeout).
/// Use for critical warnings the user must not miss, such as email delivery failures.
/// </summary>
public static void WarningPermanent(this ITempDataDictionary tempData, string message)
{
tempData[WarningPermanentKey] = message;
}
/// <summary>
/// Stores an informational (blue) toast message in TempData for display on
/// the next page render after a redirect.
@@ -123,6 +135,15 @@ namespace PowderCoating.Web.Helpers
controller.TempData.Warning(message);
}
/// <summary>
/// Stores a permanent warning (yellow, no auto-dismiss) in the controller's TempData.
/// Use for failures the user must not miss — email delivery errors, PDF generation failures.
/// </summary>
public static void ToastWarningPermanent(this Controller controller, string message)
{
controller.TempData.WarningPermanent(message);
}
/// <summary>
/// Stores an informational (blue) toast in the controller's TempData.
/// Convenience wrapper around <see cref="ToastHelper.Info"/>.
@@ -165,7 +186,7 @@ namespace PowderCoating.Web.Helpers
: $"{channel} notification was skipped.");
break;
case NotificationStatus.Failed:
controller.ToastWarning(!string.IsNullOrEmpty(log.ErrorMessage)
controller.ToastWarningPermanent(!string.IsNullOrEmpty(log.ErrorMessage)
? $"{channel} delivery failed: {log.ErrorMessage}"
: $"{channel} notification failed.");
break;
+68
View File
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Infrastructure.Services;
@@ -199,6 +200,8 @@ builder.Services.AddScoped<IAiQuickQuoteService, AiQuickQuoteService>();
builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>();
builder.Services.AddScoped<IAiSchedulingService, AiSchedulingService>();
builder.Services.AddScoped<IAccountingAiService, AccountingAiService>();
builder.Services.AddScoped<IFinancialReportService, FinancialReportService>();
builder.Services.AddScoped<IOperationalReportService, OperationalReportService>();
builder.Services.AddScoped<IAiHelpService, AiHelpService>();
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
@@ -207,6 +210,11 @@ builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
builder.Services.AddScoped<IPowderInsightsService, PowderInsightsService>();
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
builder.Services.AddScoped<IAiUsageReportService, AiUsageReportService>();
builder.Services.AddScoped<IDashboardReadService, DashboardReadService>();
builder.Services.AddScoped<ICompanyListService, CompanyListService>();
builder.Services.AddScoped<ICompanyDataPurgeService, CompanyDataPurgeService>();
builder.Services.AddScoped<IPdfService, PdfService>();
builder.Services.AddScoped<ISeedDataService, SeedDataService>();
builder.Services.AddScoped<ICompanyConfigHealthService, CompanyConfigHealthService>();
@@ -833,6 +841,11 @@ using (var scope = app.Services.CreateScope())
// Fail fast with a clear message rather than a cryptic runtime error later.
ValidateRequiredConfiguration(app.Configuration, app.Environment);
// ── Data access architecture enforcement ─────────────────────────────────────
// Throws at startup if any non-exempt controller injects ApplicationDbContext directly.
// This is the Phase 4 gate: the app cannot start with a violation.
EnforceDataAccessArchitecture();
try
{
Log.Information("Starting web application");
@@ -892,6 +905,61 @@ static void ValidateRequiredConfiguration(IConfiguration config, IWebHostEnviron
}
}
// ── Data access architecture enforcement ─────────────────────────────────────────
/// <summary>
/// Scans every Controller subclass in the Web assembly at startup and throws if any
/// non-exempt controller declares ApplicationDbContext as a constructor parameter.
/// This enforces the rule defined in docs/DATA_ACCESS_ARCHITECTURE.md — if a developer
/// adds a new controller that injects ApplicationDbContext directly, the app will refuse
/// to start with a clear message naming the violator.
/// </summary>
static void EnforceDataAccessArchitecture()
{
// Controllers in this set are documented permanent exceptions — see DATA_ACCESS_ARCHITECTURE.md.
var permanentExceptions = new HashSet<string>
{
"StripeWebhookController",
"WebhooksController",
"PaymentController",
"RegistrationController",
"DataExportController",
"AccountDataExportController",
"DataPurgeController",
"SystemInfoController",
"SystemLogsController",
"CompanyHealthController",
"PasskeyController",
"AuditLogController",
"UserActivityController",
"EmailBroadcastController",
"RevenueController",
"StripeEventsController",
"SubscriptionManagementController",
"UsageQuotaController",
};
var violators = typeof(Program).Assembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract
&& typeof(Microsoft.AspNetCore.Mvc.Controller).IsAssignableFrom(t)
&& !permanentExceptions.Contains(t.Name))
.Where(t => t.GetConstructors()
.Any(ctor => ctor.GetParameters()
.Any(p => p.ParameterType == typeof(ApplicationDbContext))))
.Select(t => t.Name)
.OrderBy(n => n)
.ToList();
if (violators.Count == 0) return;
var names = string.Join(", ", violators);
throw new InvalidOperationException(
$"DATA ACCESS VIOLATION — {violators.Count} controller(s) inject ApplicationDbContext directly " +
$"and are not in the permanent exceptions list.\n" +
$"Violators: {names}\n" +
$"Fix: route data access through IUnitOfWork. " +
$"To add a permanent exception, update both the controller comment and docs/DATA_ACCESS_ARCHITECTURE.md.");
}
// ── Serilog DB sink column configuration ─────────────────────────────────────────
static ColumnOptions BuildLogColumnOptions()
{
@@ -890,6 +890,10 @@
{
<div id="tempdata-warning-message" style="display:none;">@TempData["Warning"]</div>
}
@if (TempData["WarningPermanent"] != null)
{
<div id="tempdata-warning-permanent-message" style="display:none;">@TempData["WarningPermanent"]</div>
}
@if (TempData["Info"] != null)
{
<div id="tempdata-info-message" style="display:none;">@TempData["Info"]</div>
@@ -118,6 +118,12 @@ function displayTempDataMessages() {
showWarning(warningMsg.textContent.trim());
}
// Permanent warning — no auto-dismiss
const warningPerm = document.getElementById('tempdata-warning-permanent-message');
if (warningPerm && warningPerm.textContent.trim()) {
toastr.warning(warningPerm.textContent.trim(), 'Warning', { timeOut: 0, extendedTimeOut: 0 });
}
// Info message
const infoMsg = document.getElementById('tempdata-info-message');
if (infoMsg && infoMsg.textContent.trim()) {
@@ -290,8 +290,7 @@ public class DepositsControllerTests
var controller = new DepositsController(
uow,
userManager.Object,
Mock.Of<ILogger<DepositsController>>(),
context);
Mock.Of<ILogger<DepositsController>>());
controller.ControllerContext = new ControllerContext
{
@@ -4,6 +4,7 @@ using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Interfaces.Repositories;
using Xunit;
namespace PowderCoating.UnitTests;
@@ -548,7 +549,7 @@ public class PricingCalculationServiceTests
costs.MonthlyRent = 0m;
costs.MonthlyUtilities = 0m;
var customerRepo = new Mock<IRepository<Customer>>();
var customerRepo = new Mock<ICustomerRepository>();
customerRepo
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<Customer, object>>[]>()))
.ReturnsAsync(new[]
@@ -625,7 +626,7 @@ public class PricingCalculationServiceTests
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CatalogItem, object>>[]>()))
.ReturnsAsync(catalogItem);
var customerRepo = new Mock<IRepository<Customer>>();
var customerRepo = new Mock<ICustomerRepository>();
customerRepo
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<Customer, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<Customer, object>>[]>()))
.ReturnsAsync(Array.Empty<Customer>());