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
@@ -89,4 +89,17 @@ public class BillRepository : Repository<Bill>, IBillRepository
.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,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,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();
}
}
@@ -175,4 +175,16 @@ public class JobRepository : Repository<Job>, IJobRepository
.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();
}
}
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
@@ -60,4 +61,69 @@ public class NotificationLogRepository : Repository<NotificationLog>, INotificat
.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();
}
}
@@ -41,14 +41,14 @@ public class UnitOfWork : IUnitOfWork
private IRepository<AiItemPrediction>? _aiItemPredictions;
// Powder Insights
private IRepository<PowderUsageLog>? _powderUsageLogs;
private IPowderUsageLogRepository? _powderUsageLogs;
// Core repositories
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<JobPrepService>? _jobPrepServices;
@@ -59,13 +59,13 @@ public class UnitOfWork : IUnitOfWork
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;
@@ -97,13 +97,21 @@ public class UnitOfWork : IUnitOfWork
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;
@@ -169,8 +177,8 @@ 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>
@@ -190,8 +198,8 @@ 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);
@@ -232,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 =>
@@ -256,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 =>
@@ -372,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 =>
@@ -397,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 =>