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:
@@ -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<T> 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();
|
||||
}
|
||||
Reference in New Issue
Block a user