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>
This commit is contained in:
2026-04-28 09:17:29 -04:00
parent 90bc0d965f
commit 1cb7a8ca4a
72 changed files with 9060 additions and 2323 deletions
@@ -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);
}
@@ -14,14 +14,14 @@ public interface IUnitOfWork : IDisposable
IRepository<AiItemPrediction> AiItemPredictions { get; }
// Powder Insights
IRepository<PowderUsageLog> PowderUsageLogs { get; }
IPowderUsageLogRepository PowderUsageLogs { 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<JobPrepService> JobPrepServices { get; }
@@ -32,13 +32,13 @@ public interface IUnitOfWork : IDisposable
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; }
@@ -97,13 +97,21 @@ public interface IUnitOfWork : IDisposable
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; }
@@ -39,4 +39,11 @@ public interface IBillRepository : IRepository<Bill>
/// 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;
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,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);
}
@@ -78,4 +78,11 @@ public interface IJobRepository : IRepository<Job>
/// 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();
}
@@ -1,4 +1,5 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
@@ -27,4 +28,19 @@ public interface INotificationLogRepository : IRepository<NotificationLog>
/// <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,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();
}