Compare commits
70 Commits
931d6d40da
...
8148908a66
| Author | SHA1 | Date | |
|---|---|---|---|
| 8148908a66 | |||
| c18b580ec9 | |||
| a2d48c8b58 | |||
| d9bf80cc9a | |||
| 6569d9c4ea | |||
| 2b89fcf483 | |||
| a9a8ea41c6 | |||
| 0b798cadb4 | |||
| 49e3d73c67 | |||
| 90a06c6acd | |||
| 9221fcc783 | |||
| 167dc0c146 | |||
| ac3e4452b2 | |||
| 3669fda852 | |||
| 8de9cd04b8 | |||
| 296f85e33b | |||
| 900a52f89d | |||
| 73df72ab97 | |||
| 45441c1d07 | |||
| 64e9abceac | |||
| b1337d3b61 | |||
| 8aae30765f | |||
| 4d27a378ac | |||
| 6993c2c462 | |||
| 1cb7a8ca4a | |||
| 90bc0d965f | |||
| 80b0e547cc | |||
| 92dc3ebd08 | |||
| 5631d1d57a | |||
| cad728ba66 | |||
| 0ea192d55b | |||
| 8491b308eb | |||
| 404ab3c45d | |||
| 90a01571e3 | |||
| a4b8ae611a | |||
| 3899860c1f | |||
| f03a198e79 | |||
| cb7bbc37bd | |||
| fa9fa76231 | |||
| 4128c15bbb | |||
| 6d9111b448 | |||
| 37c95192ca | |||
| 03c10a3d77 | |||
| ff79c39e83 | |||
| 2d25f6db2b | |||
| 47f186384f | |||
| 26b8244422 | |||
| 7b902d90a2 | |||
| f05e16a826 | |||
| 97d47dbd1c | |||
| 7407d1cd96 | |||
| 740238a939 | |||
| 560a2c76b8 | |||
| 19cc03ad1c | |||
| 9370fcdd8f | |||
| 2c4c1a6846 | |||
| c9324ee0b0 | |||
| 9943c11571 | |||
| 360edace72 | |||
| 54f444d981 | |||
| dbe4170986 | |||
| edce8e8c4a | |||
| 92f71f62d0 | |||
| c71332740e | |||
| edc599a1a2 | |||
| 90a5a028ad | |||
| 0bb96a502a | |||
| 4f976b1332 | |||
| 9361cd4495 | |||
| 00bf8a4cd0 |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "8.0.11",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Jenkins Production Deployment Setup
|
||||
|
||||
## What was created
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `Jenkinsfile` | Production pipeline — manual trigger only |
|
||||
| `jenkins/Dockerfile` | Custom image: Jenkins LTS + .NET 8 + Azure CLI + sqlcmd + dotnet-ef |
|
||||
| `.config/dotnet-tools.json` | Tool manifest pinning dotnet-ef 8.0.11 |
|
||||
|
||||
---
|
||||
|
||||
## One-time setup steps
|
||||
|
||||
### 1. Build and run your custom Jenkins image
|
||||
|
||||
On your Ubuntu Docker host:
|
||||
```bash
|
||||
cd /path/to/repo
|
||||
docker build -t pcl-jenkins ./jenkins
|
||||
docker run -d -p 8080:8080 -p 50000:50000 \
|
||||
-v jenkins_home:/var/jenkins_home \
|
||||
--name pcl-jenkins pcl-jenkins
|
||||
```
|
||||
|
||||
If you already have a Jenkins container running, rebuild the image and recreate the container (volume data is preserved).
|
||||
|
||||
---
|
||||
|
||||
### 2. Create an Azure Service Principal
|
||||
|
||||
Run this once from **your machine** (not Jenkins):
|
||||
```bash
|
||||
az login
|
||||
az ad sp create-for-rbac \
|
||||
--name "pcl-jenkins-deploy" \
|
||||
--role contributor \
|
||||
--scopes /subscriptions/<YOUR_SUBSCRIPTION_ID>/resourceGroups/<YOUR_RG>
|
||||
```
|
||||
|
||||
Save the output — you need `appId`, `password`, `tenant`, and your subscription ID.
|
||||
|
||||
---
|
||||
|
||||
### 3. Create a SQL Server deployment login
|
||||
|
||||
In SSMS or Azure portal query editor, run on your Azure SQL server (as admin):
|
||||
```sql
|
||||
CREATE LOGIN pcl_deploy WITH PASSWORD = 'ChooseAStrongPassword123!';
|
||||
USE PowderCoatingDb;
|
||||
CREATE USER pcl_deploy FOR LOGIN pcl_deploy;
|
||||
ALTER ROLE db_owner ADD MEMBER pcl_deploy; -- needs DDL rights for migrations
|
||||
```
|
||||
|
||||
> After migrations are stable you can demote this to `db_datareader`/`db_datawriter` + explicit DDL permissions, but `db_owner` is easiest to start.
|
||||
|
||||
---
|
||||
|
||||
### 4. Add Jenkins credentials
|
||||
|
||||
Go to **Jenkins → Manage Jenkins → Credentials → System → Global** and add 10 **Secret Text** credentials with these exact IDs:
|
||||
|
||||
| Credential ID | Value |
|
||||
|---|---|
|
||||
| `PCL_AZURE_CLIENT_ID` | `appId` from step 2 |
|
||||
| `PCL_AZURE_CLIENT_SECRET` | `password` from step 2 |
|
||||
| `PCL_AZURE_TENANT_ID` | `tenant` from step 2 |
|
||||
| `PCL_AZURE_SUBSCRIPTION_ID` | Your Azure subscription GUID |
|
||||
| `PCL_AZURE_RESOURCE_GROUP` | e.g. `powder-coating-prod` |
|
||||
| `PCL_AZURE_APP_NAME` | Your App Service name (e.g. `pcl-app`) |
|
||||
| `PCL_SQL_SERVER` | e.g. `pcl-sql.database.windows.net` |
|
||||
| `PCL_SQL_DATABASE` | e.g. `PowderCoatingDb` |
|
||||
| `PCL_SQL_USER` | `pcl_deploy` |
|
||||
| `PCL_SQL_PASSWORD` | The password you set in step 3 |
|
||||
|
||||
---
|
||||
|
||||
### 5. Create the Jenkins Pipeline job
|
||||
|
||||
1. **New Item → Pipeline** — name it "PCL Production Deploy"
|
||||
2. Under **Pipeline**, set **Definition** = `Pipeline script from SCM`
|
||||
3. SCM = Git, repo URL, branch `*/master`, Script Path = `Jenkinsfile`
|
||||
4. **Do NOT** check any triggers (no poll SCM, no build periodically, no webhook)
|
||||
5. Save
|
||||
|
||||
To deploy: open the job → **Build Now**. That's your "Go!" button.
|
||||
|
||||
---
|
||||
|
||||
## How each stage works
|
||||
|
||||
| Stage | What happens |
|
||||
|---|---|
|
||||
| **Checkout** | Pulls `master`, logs the commit SHA |
|
||||
| **Build & Test** | `dotnet restore` → `dotnet build -c Release` → `dotnet test` (results published to Jenkins) |
|
||||
| **Publish** | `dotnet publish -c Release` → `./publish/` |
|
||||
| **Generate Migration Script** | `dotnet ef migrations script --idempotent` — no DB connection needed. Script is **archived as a build artifact** so you can inspect it before or after |
|
||||
| **Apply Migration** | `sqlcmd` runs the idempotent script against Azure SQL. `-b` flag makes it fail-fast on errors |
|
||||
| **Deploy to Azure** | ZIP the publish folder, `az webapp deployment source config-zip` |
|
||||
| **Smoke Test** | `curl` the App Service root URL — expects HTTP 200 or 302 |
|
||||
Vendored
+168
@@ -0,0 +1,168 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
// No triggers — start this pipeline manually from the Jenkins UI only.
|
||||
|
||||
environment {
|
||||
DOTNET_CLI_HOME = '/tmp/dotnet_cli_home'
|
||||
WEB_PROJECT = 'src/PowderCoating.Web/PowderCoating.Web.csproj'
|
||||
INFRA_PROJECT = 'src/PowderCoating.Infrastructure/PowderCoating.Infrastructure.csproj'
|
||||
PUBLISH_DIR = "${WORKSPACE}/publish"
|
||||
DEPLOY_ZIP = "${WORKSPACE}/deploy_${BUILD_NUMBER}.zip"
|
||||
MIGRATION_SQL = "${WORKSPACE}/migration_${BUILD_NUMBER}.sql"
|
||||
}
|
||||
|
||||
stages {
|
||||
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: 'refs/heads/master']],
|
||||
userRemoteConfigs: scm.userRemoteConfigs
|
||||
])
|
||||
echo "Building commit: ${GIT_COMMIT}"
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build & Test') {
|
||||
steps {
|
||||
sh 'dotnet restore'
|
||||
sh 'dotnet build --no-restore -c Release'
|
||||
sh '''
|
||||
dotnet test --no-build -c Release \
|
||||
--logger "trx;LogFileName=results.trx" \
|
||||
--results-directory TestResults
|
||||
'''
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit testResults: 'TestResults/*.trx', allowEmptyResults: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Publish') {
|
||||
steps {
|
||||
sh """
|
||||
dotnet publish '${WEB_PROJECT}' \
|
||||
-c Release --no-build \
|
||||
-o '${PUBLISH_DIR}'
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
// Generates an idempotent SQL migration script (no live DB connection required).
|
||||
// The script checks which migrations have already been applied before running each one.
|
||||
stage('Generate Migration Script') {
|
||||
steps {
|
||||
sh """
|
||||
dotnet ef migrations script \
|
||||
--idempotent \
|
||||
--output '${MIGRATION_SQL}' \
|
||||
--project '${INFRA_PROJECT}' \
|
||||
--startup-project '${WEB_PROJECT}' \
|
||||
--context ApplicationDbContext \
|
||||
--no-build
|
||||
"""
|
||||
archiveArtifacts artifacts: "migration_${BUILD_NUMBER}.sql", fingerprint: true
|
||||
echo "Migration script archived — review it in the Jenkins build artifacts before this pipeline runs next time."
|
||||
}
|
||||
}
|
||||
|
||||
stage('Apply Migration to Azure SQL') {
|
||||
steps {
|
||||
withCredentials([
|
||||
string(credentialsId: 'PCL_SQL_SERVER', variable: 'SQL_SERVER'),
|
||||
string(credentialsId: 'PCL_SQL_DATABASE', variable: 'SQL_DATABASE'),
|
||||
string(credentialsId: 'PCL_SQL_USER', variable: 'SQL_USER'),
|
||||
string(credentialsId: 'PCL_SQL_PASSWORD', variable: 'SQL_PASSWORD')
|
||||
]) {
|
||||
sh '''
|
||||
echo "Applying migration to ${SQL_SERVER}/${SQL_DATABASE} ..."
|
||||
/opt/mssql-tools18/bin/sqlcmd \
|
||||
-S "${SQL_SERVER}" \
|
||||
-d "${SQL_DATABASE}" \
|
||||
-U "${SQL_USER}" \
|
||||
-P "${SQL_PASSWORD}" \
|
||||
-C \
|
||||
-b \
|
||||
-i "${MIGRATION_SQL}"
|
||||
echo "Migration applied successfully."
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy to Azure App Service') {
|
||||
steps {
|
||||
withCredentials([
|
||||
string(credentialsId: 'PCL_AZURE_CLIENT_ID', variable: 'AZ_CLIENT_ID'),
|
||||
string(credentialsId: 'PCL_AZURE_CLIENT_SECRET', variable: 'AZ_CLIENT_SECRET'),
|
||||
string(credentialsId: 'PCL_AZURE_TENANT_ID', variable: 'AZ_TENANT_ID'),
|
||||
string(credentialsId: 'PCL_AZURE_SUBSCRIPTION_ID', variable: 'AZ_SUBSCRIPTION_ID'),
|
||||
string(credentialsId: 'PCL_AZURE_RESOURCE_GROUP', variable: 'AZ_RG'),
|
||||
string(credentialsId: 'PCL_AZURE_APP_NAME', variable: 'AZ_APP')
|
||||
]) {
|
||||
sh '''
|
||||
az login --service-principal \
|
||||
--username "$AZ_CLIENT_ID" \
|
||||
--password "$AZ_CLIENT_SECRET" \
|
||||
--tenant "$AZ_TENANT_ID" \
|
||||
--output none
|
||||
|
||||
az account set --subscription "$AZ_SUBSCRIPTION_ID"
|
||||
|
||||
echo "Packaging deployment artifact ..."
|
||||
cd "$PUBLISH_DIR"
|
||||
zip -r "$DEPLOY_ZIP" .
|
||||
|
||||
echo "Pushing ZIP to ${AZ_APP} ..."
|
||||
az webapp deployment source config-zip \
|
||||
--resource-group "$AZ_RG" \
|
||||
--name "$AZ_APP" \
|
||||
--src "$DEPLOY_ZIP"
|
||||
|
||||
az logout
|
||||
echo "Deploy complete."
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Smoke Test') {
|
||||
steps {
|
||||
withCredentials([
|
||||
string(credentialsId: 'PCL_AZURE_APP_NAME', variable: 'AZ_APP')
|
||||
]) {
|
||||
sh '''
|
||||
APP_URL="https://${AZ_APP}.azurewebsites.net"
|
||||
echo "Smoke-testing ${APP_URL} ..."
|
||||
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--max-time 45 --retry 3 --retry-delay 10 \
|
||||
"${APP_URL}")
|
||||
echo "HTTP status: ${HTTP_STATUS}"
|
||||
# 200 = OK, 302 = redirect to login (both are healthy)
|
||||
if [ "$HTTP_STATUS" != "200" ] && [ "$HTTP_STATUS" != "302" ]; then
|
||||
echo "SMOKE TEST FAILED — got HTTP ${HTTP_STATUS}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Smoke test passed."
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
echo "Production deployment #${BUILD_NUMBER} (${GIT_COMMIT}) completed successfully."
|
||||
}
|
||||
failure {
|
||||
echo "Pipeline #${BUILD_NUMBER} FAILED — review the stage logs above."
|
||||
}
|
||||
always {
|
||||
cleanWs()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
Shop Management App TO DO List
|
||||
==============================
|
||||
-Look into possibly having AI scan a product catalog and suggest prices for items.
|
||||
-Add images to product catalog items for easily identification of parts
|
||||
-AI Company Lookup (similar to inventory lookup)
|
||||
|
||||
|
||||
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
|
||||
|
||||
-Check my ChatGPT chat about surface area for a few solid ideas for the system
|
||||
|
||||
-Add SMS capabilities
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Shop Management App TO DO List
|
||||
==============================
|
||||
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
|
||||
-Check my ChatGPT chat about surface area for a few solid ideas for the system
|
||||
|
||||
-Add SMS capabilities
|
||||
@@ -172,6 +173,7 @@ AI Agent item where we upload a picture and it will calculate the approximate sq
|
||||
-Allow printing blank work orders (model after the SCP Powder Coating blank work order)
|
||||
-IDEA: Print powders to use on work order with their QR code so they can be scanned right from there and usage recorded.
|
||||
|
||||
|
||||
Ideas Removed
|
||||
=======================
|
||||
-Add Deactivate Customer button on Customer Detail page
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Custom Jenkins image for Powder Coating Logix production deployments.
|
||||
# Adds: .NET 8 SDK, Azure CLI, sqlcmd (mssql-tools18), dotnet-ef global tool.
|
||||
#
|
||||
# Build: docker build -t pcl-jenkins ./jenkins
|
||||
# Run: docker run -d -p 8080:8080 -p 50000:50000 \
|
||||
# -v jenkins_home:/var/jenkins_home \
|
||||
# --name pcl-jenkins pcl-jenkins
|
||||
|
||||
FROM jenkins/jenkins:lts
|
||||
|
||||
USER root
|
||||
|
||||
# ── Base utilities ────────────────────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget curl gnupg2 apt-transport-https lsb-release zip unzip ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── .NET 8 SDK ────────────────────────────────────────────────────────────────
|
||||
RUN wget -q https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb \
|
||||
-O /tmp/ms-prod.deb \
|
||||
&& dpkg -i /tmp/ms-prod.deb \
|
||||
&& rm /tmp/ms-prod.deb \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends dotnet-sdk-8.0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Azure CLI ─────────────────────────────────────────────────────────────────
|
||||
RUN curl -sL https://aka.ms/InstallAzureCLIDeb | bash \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── mssql-tools18 (sqlcmd) ───────────────────────────────────────────────────
|
||||
RUN curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \
|
||||
| gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/microsoft-prod.gpg] \
|
||||
https://packages.microsoft.com/debian/12/prod bookworm main" \
|
||||
> /etc/apt/sources.list.d/mssql-release.list \
|
||||
&& apt-get update \
|
||||
&& ACCEPT_EULA=Y apt-get install -y --no-install-recommends \
|
||||
mssql-tools18 unixodbc-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="$PATH:/opt/mssql-tools18/bin"
|
||||
|
||||
# ── dotnet-ef global tool ─────────────────────────────────────────────────────
|
||||
# Installed into /root/.dotnet/tools (not JENKINS_HOME, which is a volume mount
|
||||
# and would be wiped on first run). A symlink exposes it system-wide.
|
||||
RUN DOTNET_CLI_HOME=/root dotnet tool install --global dotnet-ef --version 8.0.11 \
|
||||
&& ln -s /root/.dotnet/tools/dotnet-ef /usr/local/bin/dotnet-ef
|
||||
|
||||
USER jenkins
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,4 +15,5 @@ public class StorageContainers
|
||||
public string ReceiptImages { get; set; } = "receiptimages";
|
||||
public string QuoteImages { get; set; } = "quoteimages";
|
||||
public string BugReportMedia { get; set; } = "bugreportmedia";
|
||||
public string CatalogImages { get; set; } = "catalogimages";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace PowderCoating.Application.DTOs.AI;
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Lightweight representation of a catalog item sent to Claude for analysis.</summary>
|
||||
public class CatalogItemForPriceCheck
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string CategoryName { get; set; } = string.Empty;
|
||||
public decimal CurrentPrice { get; set; }
|
||||
public decimal? ApproximateAreaSqFt { get; set; }
|
||||
public int? EstimatedMinutes { get; set; }
|
||||
public bool RequiresSandblasting { get; set; }
|
||||
public bool RequiresMasking { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Operating cost summary injected into the Claude system prompt.</summary>
|
||||
public class ShopOperatingCostSummary
|
||||
{
|
||||
public decimal LaborRatePerHour { get; set; }
|
||||
public decimal OvenCostPerHour { get; set; }
|
||||
public decimal SandblasterCostPerHour { get; set; }
|
||||
public decimal CoatingBoothCostPerHour { get; set; }
|
||||
public decimal PowderCostPerSqFt { get; set; }
|
||||
public decimal ShopSuppliesRatePercent { get; set; }
|
||||
public decimal MarkupOrMarginPercent { get; set; }
|
||||
public string PricingMode { get; set; } = "markup"; // "markup" or "margin"
|
||||
public decimal ShopMinimumCharge { get; set; }
|
||||
public string? AiContextProfile { get; set; }
|
||||
}
|
||||
|
||||
// ── Per-Item Result ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verdict on a single catalog item's price health.</summary>
|
||||
public class CatalogItemPriceVerdict
|
||||
{
|
||||
public int CatalogItemId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public decimal CurrentPrice { get; set; }
|
||||
|
||||
/// <summary>Assumptions Claude made about size/complexity to estimate costs.</summary>
|
||||
public string Assumptions { get; set; } = string.Empty;
|
||||
|
||||
public decimal EstimatedSqFtMin { get; set; }
|
||||
public decimal EstimatedSqFtMax { get; set; }
|
||||
public int EstimatedMinutesMin { get; set; }
|
||||
public int EstimatedMinutesMax { get; set; }
|
||||
|
||||
/// <summary>Calculated cost floor using the shop's own rates.</summary>
|
||||
public decimal CostFloor { get; set; }
|
||||
|
||||
/// <summary>ok | low | high | below-cost</summary>
|
||||
public string Verdict { get; set; } = "ok";
|
||||
|
||||
public decimal SuggestedPriceMin { get; set; }
|
||||
public decimal SuggestedPriceMax { get; set; }
|
||||
|
||||
/// <summary>high | medium | low</summary>
|
||||
public string Confidence { get; set; } = "medium";
|
||||
|
||||
public string Reasoning { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────────
|
||||
|
||||
public class CatalogPriceCheckReportDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime RunAt { get; set; }
|
||||
public int ItemsChecked { get; set; }
|
||||
public int BelowCostCount { get; set; }
|
||||
public int LowMarginCount { get; set; }
|
||||
public int HighPriceCount { get; set; }
|
||||
public int OkCount { get; set; }
|
||||
public List<CatalogItemPriceVerdict> Results { get; set; } = new();
|
||||
public string OperatingCostsSummary { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ namespace PowderCoating.Application.DTOs.Catalog
|
||||
[Display(Name = "COGS Account")]
|
||||
public int? CogsAccountId { get; set; }
|
||||
public string? CogsAccountName { get; set; }
|
||||
|
||||
public string? ImagePath { get; set; }
|
||||
public string? ThumbnailPath { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -43,6 +46,7 @@ namespace PowderCoating.Application.DTOs.Catalog
|
||||
public string CategoryName { get; set; } = string.Empty;
|
||||
public decimal DefaultPrice { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public string? ThumbnailPath { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -158,12 +158,16 @@ public class UpdateCompanyDto
|
||||
// AI feature flags
|
||||
public bool AiPhotoQuotesEnabled { get; set; }
|
||||
public bool AiInventoryAssistEnabled { get; set; }
|
||||
public bool AiCatalogPriceCheckEnabled { get; set; }
|
||||
public int? MaxAiPhotoQuotesPerMonthOverride { get; set; }
|
||||
|
||||
// Per-company feature overrides (null = use plan default)
|
||||
public bool? OnlinePaymentsOverride { get; set; }
|
||||
public bool? AccountingOverride { get; set; }
|
||||
|
||||
/// <summary>When true, SuperAdmin has force-disabled SMS for this company regardless of plan or company settings.</summary>
|
||||
public bool SmsDisabledByAdmin { get; set; }
|
||||
|
||||
public string? TimeZone { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,26 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
public decimal OnlinePaymentSurchargeValue { get; set; }
|
||||
public bool OnlineSurchargeAcknowledged { get; set; }
|
||||
public bool AllowOnlinePayments { get; set; }
|
||||
|
||||
// SMS gating
|
||||
public bool AllowSms { get; set; }
|
||||
public bool SmsEnabled { get; set; }
|
||||
public bool SmsDisabledByAdmin { get; set; }
|
||||
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
|
||||
public bool HasCurrentSmsAgreement { get; set; }
|
||||
public string SmsTermsVersion { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for the company admin SMS opt-in/out toggle.
|
||||
/// When enabling for the first time (or after a terms version change), AgreedToTerms must
|
||||
/// be true and TermsVersion must match <c>AppConstants.SmsTermsVersion</c>.
|
||||
/// </summary>
|
||||
public class UpdateSmsPreferencesDto
|
||||
{
|
||||
public bool SmsEnabled { get; set; }
|
||||
public bool AgreedToTerms { get; set; }
|
||||
public string? TermsVersion { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -23,6 +23,7 @@ public class InventoryItemDto
|
||||
public string? ColorFamilies { get; set; }
|
||||
public bool RequiresClearCoat { get; set; }
|
||||
public string? SpecPageUrl { get; set; }
|
||||
public string? ImageUrl { get; set; }
|
||||
public decimal QuantityOnHand { get; set; }
|
||||
public string UnitOfMeasure { get; set; } = "lbs";
|
||||
public decimal ReorderPoint { get; set; }
|
||||
@@ -144,6 +145,10 @@ public class CreateInventoryItemDto
|
||||
[Display(Name = "Product URL")]
|
||||
public string? SpecPageUrl { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessage = "Image URL cannot exceed 1000 characters")]
|
||||
[Display(Name = "Product Image URL")]
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
[Range(0, 999999999, ErrorMessage = "Quantity on hand must be 0 or greater")]
|
||||
[Display(Name = "Quantity on Hand")]
|
||||
public decimal QuantityOnHand { get; set; }
|
||||
|
||||
@@ -52,6 +52,10 @@ public class JobDto
|
||||
public bool RequiresCustomerApproval { get; set; }
|
||||
public bool IsCustomerApproved { get; set; }
|
||||
|
||||
// Customer SMS opt-in — used for SMS compose modal on job details
|
||||
public bool CustomerNotifyBySms { get; set; }
|
||||
public string? CustomerMobilePhone { get; set; }
|
||||
|
||||
// Job Completion Details
|
||||
public decimal? ActualTimeSpentHours { get; set; }
|
||||
|
||||
@@ -99,6 +103,7 @@ public class JobListDto
|
||||
public string PriorityDisplayName { get; set; } = string.Empty;
|
||||
public string PriorityColorClass { get; set; } = "secondary";
|
||||
|
||||
public bool CustomerNotifyByEmail { get; set; } = true;
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
@@ -379,6 +384,13 @@ public class CompleteJobDto
|
||||
public bool SendEmailToCustomer { get; set; } = false;
|
||||
}
|
||||
|
||||
// DTO for the Admin/Manager compose-before-send SMS endpoint
|
||||
public class SendJobSmsRequest
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// DTO for tracking actual powder usage per coat
|
||||
public class JobItemCoatUsageDto
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ public class NotificationLogDto
|
||||
public NotificationType NotificationType { get; set; }
|
||||
public string NotificationTypeDisplay => NotificationType switch
|
||||
{
|
||||
NotificationType.AdminEmail => "Admin Email",
|
||||
NotificationType.QuoteSent => "Quote Sent",
|
||||
NotificationType.QuoteApproved => "Quote Approved",
|
||||
NotificationType.JobStatusChanged => "Job Status Changed",
|
||||
|
||||
@@ -24,6 +24,8 @@ public class SubscriptionPlanConfigDto
|
||||
public bool AllowAccounting { get; set; }
|
||||
public bool AllowAiPhotoQuotes { get; set; }
|
||||
public bool AllowAiInventoryAssist { get; set; }
|
||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||
public bool AllowSms { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -70,6 +72,8 @@ public class UpdateSubscriptionPlanConfigDto
|
||||
public bool AllowAccounting { get; set; }
|
||||
public bool AllowAiPhotoQuotes { get; set; }
|
||||
public bool AllowAiInventoryAssist { get; set; }
|
||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||
public bool AllowSms { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public class WizardProgressDto
|
||||
public bool Completed { get; set; }
|
||||
public List<int> DoneSteps { get; set; } = new();
|
||||
public List<int> SkippedSteps { get; set; } = new();
|
||||
public const int TotalSteps = 10;
|
||||
public const int TotalSteps = 5;
|
||||
|
||||
public bool IsStepDone(int step) => DoneSteps.Contains(step);
|
||||
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using PowderCoating.Application.DTOs.AI;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface IAiCatalogPriceCheckService
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyzes the provided catalog items against the shop's operating costs and returns
|
||||
/// a verdict for each item. Items are batched into groups of 25 to stay within Claude's
|
||||
/// context limits. Returns null results for any item that could not be analyzed.
|
||||
/// </summary>
|
||||
Task<List<CatalogItemPriceVerdict>> AnalyzeAsync(
|
||||
List<CatalogItemForPriceCheck> items,
|
||||
ShopOperatingCostSummary costs,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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,32 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Handles upload, thumbnail generation, and deletion of catalog item images stored in Azure Blob Storage.
|
||||
/// All blobs are scoped under {companyId}/catalog/{itemId}/ so tenants never share a path.
|
||||
/// </summary>
|
||||
public interface ICatalogImageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads the image, generates a 200×200 JPEG thumbnail, stores both blobs, and returns their paths.
|
||||
/// On success the caller should persist the returned paths to <c>CatalogItem.ImagePath</c> and
|
||||
/// <c>CatalogItem.ThumbnailPath</c>. Any previously stored blobs for the same item are deleted first.
|
||||
/// </summary>
|
||||
Task<(bool Success, string ImagePath, string ThumbnailPath, string ErrorMessage)> UploadAsync(
|
||||
IFormFile file,
|
||||
int itemId,
|
||||
int companyId,
|
||||
string? existingImagePath,
|
||||
string? existingThumbnailPath);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a catalog image blob and returns its raw bytes and content-type for streaming to the browser.
|
||||
/// </summary>
|
||||
Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(string blobPath);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes both the full-size image and thumbnail blobs. Safe to call with null paths.
|
||||
/// </summary>
|
||||
Task DeleteAsync(string? imagePath, string? thumbnailPath);
|
||||
}
|
||||
@@ -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 & 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 & Income report for the given company and date range.</summary>
|
||||
Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public class InventoryAiLookupResult
|
||||
public decimal? UnitCostPerLb { get; set; } // price per lb/unit if found in search results
|
||||
public string? VendorName { get; set; } // manufacturer/vendor name for dropdown matching
|
||||
public string? SpecPageUrl { get; set; } // URL of the product page that was fetched
|
||||
public string? ImageUrl { get; set; } // og:image or first product image found on the page
|
||||
|
||||
public string? Reasoning { get; set; } // brief explanation of what was found
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ public interface INotificationService
|
||||
/// </summary>
|
||||
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the quote approval link to the customer via SMS.
|
||||
/// Handles both registered customers (respects NotifyBySms) and prospects (ProspectPhone).
|
||||
/// Returns (success, errorMessage) so the caller can surface the result to the user.
|
||||
/// </summary>
|
||||
Task<(bool Success, string? Error)> NotifyQuoteSentSmsAsync(Quote quote);
|
||||
|
||||
/// <summary>
|
||||
/// Notify when a quote is approved by a customer.
|
||||
/// </summary>
|
||||
@@ -23,8 +30,23 @@ public interface INotificationService
|
||||
|
||||
/// <summary>
|
||||
/// Notify customer when a job is completed and ready for pickup.
|
||||
/// When <paramref name="suppressSms"/> is true the SMS is skipped so an admin can review
|
||||
/// the message via <see cref="RenderJobCompletedSmsAsync"/> before sending manually.
|
||||
/// </summary>
|
||||
Task NotifyJobCompletedAsync(Job job);
|
||||
Task NotifyJobCompletedAsync(Job job, bool suppressSms = false);
|
||||
|
||||
/// <summary>
|
||||
/// Renders the job-completed SMS text for admin preview without sending it.
|
||||
/// Returns null when SMS is not allowed for the company or the customer has not opted in.
|
||||
/// </summary>
|
||||
Task<string?> RenderJobCompletedSmsAsync(Job job);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a manually-composed SMS for a job (Admin/Manager compose-before-send path).
|
||||
/// Appends "Reply STOP to opt out." if not already present, sends, and writes a NotificationLog row.
|
||||
/// Returns (success, errorMessage).
|
||||
/// </summary>
|
||||
Task<(bool Success, string? Error)> SendJobSmsAsync(Job job, string message);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a welcome/confirmation SMS after staff records verbal SMS consent.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace PowderCoating.Application.Interfaces;
|
||||
public interface IPlatformSettingsService
|
||||
{
|
||||
Task<string?> GetAsync(string key);
|
||||
Task<bool> GetBoolAsync(string key, bool defaultValue = false);
|
||||
Task SetAsync(string key, string? value, string? updatedBy = null);
|
||||
Task<IReadOnlyList<PlatformSetting>> GetAllAsync();
|
||||
}
|
||||
|
||||
@@ -92,7 +92,10 @@ namespace PowderCoating.Application.Mappings
|
||||
.ForMember(dest => dest.IsDeleted, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.Category, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.RevenueAccount, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore());
|
||||
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore())
|
||||
// Image paths are set by CatalogImageService after the entity is saved, not from the DTO.
|
||||
.ForMember(dest => dest.ImagePath, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.ThumbnailPath, opt => opt.Ignore());
|
||||
|
||||
// UpdateCatalogItemDto -> CatalogItem
|
||||
CreateMap<UpdateCatalogItemDto, CatalogItem>()
|
||||
@@ -104,7 +107,9 @@ namespace PowderCoating.Application.Mappings
|
||||
.ForMember(dest => dest.IsDeleted, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.Category, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.RevenueAccount, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore());
|
||||
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.ImagePath, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.ThumbnailPath, opt => opt.Ignore());
|
||||
|
||||
// CatalogItem -> UpdateCatalogItemDto (reverse mapping for Edit)
|
||||
CreateMap<CatalogItem, UpdateCatalogItemDto>();
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -57,7 +57,13 @@ public class JobProfile : Profile
|
||||
.ForMember(dest => dest.OriginalJobNumber,
|
||||
opt => opt.MapFrom(src => src.OriginalJob != null ? src.OriginalJob.JobNumber : null))
|
||||
.ForMember(dest => dest.IntakeCheckedByName,
|
||||
opt => opt.MapFrom(src => src.IntakeCheckedBy != null ? src.IntakeCheckedBy.FullName : null));
|
||||
opt => opt.MapFrom(src => src.IntakeCheckedBy != null ? src.IntakeCheckedBy.FullName : null))
|
||||
.ForMember(dest => dest.CustomerNotifyBySms,
|
||||
opt => opt.MapFrom(src => src.Customer != null && src.Customer.NotifyBySms))
|
||||
.ForMember(dest => dest.CustomerMobilePhone,
|
||||
opt => opt.MapFrom(src => src.Customer != null
|
||||
? (src.Customer.MobilePhone ?? src.Customer.Phone)
|
||||
: null));
|
||||
|
||||
// JobTimeEntry → JobTimeEntryDto
|
||||
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
||||
@@ -109,7 +115,9 @@ public class JobProfile : Profile
|
||||
.ForMember(dest => dest.JobPriorityId, opt => opt.MapFrom(src => src.JobPriorityId))
|
||||
.ForMember(dest => dest.PriorityCode, opt => opt.MapFrom(src => src.JobPriority.PriorityCode))
|
||||
.ForMember(dest => dest.PriorityDisplayName, opt => opt.MapFrom(src => src.JobPriority.DisplayName))
|
||||
.ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass));
|
||||
.ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass))
|
||||
.ForMember(dest => dest.CustomerNotifyByEmail,
|
||||
opt => opt.MapFrom(src => src.Customer == null || src.Customer.NotifyByEmail));
|
||||
|
||||
// JobItem mappings
|
||||
CreateMap<JobItem, JobItemDto>()
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
|
||||
<PackageReference Include="QuestPDF" Version="2024.12.3" />
|
||||
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<!-- Force newer versions of transitive packages with known CVEs -->
|
||||
<PackageReference Include="System.Formats.Asn1" Version="8.0.1" />
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.5" />
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages catalog item images in Azure Blob Storage. Each upload produces a full-size original and a
|
||||
/// 200×200 JPEG thumbnail. Both blobs are stored under <c>{companyId}/catalog/{itemId}/</c> so that
|
||||
/// paths are isolated per tenant and per item — existing blobs for the same item are replaced atomically
|
||||
/// (delete-then-upload) to avoid orphaned files accumulating over time.
|
||||
/// </summary>
|
||||
public class CatalogImageService : ICatalogImageService
|
||||
{
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
private readonly StorageSettings _settings;
|
||||
private readonly ILogger<CatalogImageService> _logger;
|
||||
|
||||
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
||||
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
|
||||
private const int ThumbnailSize = 200;
|
||||
|
||||
public CatalogImageService(
|
||||
IAzureBlobStorageService blobService,
|
||||
IOptions<StorageSettings> settings,
|
||||
ILogger<CatalogImageService> logger)
|
||||
{
|
||||
_blobService = blobService;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the upload, removes any existing blobs, stores the original, generates a 200×200 JPEG
|
||||
/// thumbnail, stores the thumbnail, and returns both blob paths. The thumbnail is always stored as
|
||||
/// JPEG regardless of the source format for predictable browser rendering and smaller file sizes.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string ImagePath, string ThumbnailPath, string ErrorMessage)> UploadAsync(
|
||||
IFormFile file,
|
||||
int itemId,
|
||||
int companyId,
|
||||
string? existingImagePath,
|
||||
string? existingThumbnailPath)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > MaxFileSizeBytes)
|
||||
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp.");
|
||||
|
||||
var container = _settings.Containers.CatalogImages;
|
||||
var blobId = Guid.NewGuid().ToString("N");
|
||||
var imagePath = $"{companyId}/catalog/{itemId}/{blobId}{ext}";
|
||||
var thumbPath = $"{companyId}/catalog/{itemId}/thumb_{blobId}.jpg";
|
||||
|
||||
// Delete existing blobs before uploading replacements.
|
||||
await DeleteAsync(existingImagePath, existingThumbnailPath);
|
||||
|
||||
// Upload original.
|
||||
using var originalStream = file.OpenReadStream();
|
||||
var uploadResult = await _blobService.UploadAsync(container, imagePath, originalStream, file.ContentType);
|
||||
if (!uploadResult.Success)
|
||||
return (false, string.Empty, string.Empty, uploadResult.ErrorMessage);
|
||||
|
||||
// Generate and upload thumbnail.
|
||||
using var thumbStream = await GenerateThumbnailAsync(file);
|
||||
if (thumbStream == null)
|
||||
{
|
||||
// Thumbnail generation failed; clean up the original and bail out.
|
||||
await _blobService.DeleteAsync(container, imagePath);
|
||||
return (false, string.Empty, string.Empty, "Failed to generate thumbnail.");
|
||||
}
|
||||
|
||||
var thumbResult = await _blobService.UploadAsync(container, thumbPath, thumbStream, "image/jpeg");
|
||||
if (!thumbResult.Success)
|
||||
{
|
||||
await _blobService.DeleteAsync(container, imagePath);
|
||||
return (false, string.Empty, string.Empty, thumbResult.ErrorMessage);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Catalog image uploaded for item {ItemId}: {ImagePath}", itemId, imagePath);
|
||||
return (true, imagePath, thumbPath, string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(string blobPath)
|
||||
{
|
||||
return await _blobService.DownloadAsync(_settings.Containers.CatalogImages, blobPath);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task DeleteAsync(string? imagePath, string? thumbnailPath)
|
||||
{
|
||||
var container = _settings.Containers.CatalogImages;
|
||||
if (!string.IsNullOrEmpty(imagePath))
|
||||
await _blobService.DeleteAsync(container, imagePath);
|
||||
if (!string.IsNullOrEmpty(thumbnailPath))
|
||||
await _blobService.DeleteAsync(container, thumbnailPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the uploaded image with ImageSharp, resizes it to fit within a 200×200 square while
|
||||
/// preserving aspect ratio, and encodes the result as JPEG. Returns null if decoding fails so the
|
||||
/// caller can surface a clean error without propagating an ImageSharp exception.
|
||||
/// </summary>
|
||||
private async Task<MemoryStream?> GenerateThumbnailAsync(IFormFile file)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var inputStream = file.OpenReadStream();
|
||||
using var image = await Image.LoadAsync(inputStream);
|
||||
|
||||
image.Mutate(ctx => ctx.Resize(new ResizeOptions
|
||||
{
|
||||
Size = new Size(ThumbnailSize, ThumbnailSize),
|
||||
Mode = ResizeMode.Max
|
||||
}));
|
||||
|
||||
var ms = new MemoryStream();
|
||||
await image.SaveAsync(ms, new JpegEncoder { Quality = 85 });
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Thumbnail generation failed for file {FileName}", file.FileName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ namespace PowderCoating.Application.Services;
|
||||
/// </summary>
|
||||
public class FileService : IFileService
|
||||
{
|
||||
private const string UploadsRootFolder = "uploads";
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<FileService> _logger;
|
||||
|
||||
@@ -31,7 +32,9 @@ public class FileService : IFileService
|
||||
/// Validation order: null/empty check, size limit, then extension allowlist. The original file
|
||||
/// name is sanitised with <see cref="Path.GetFileName"/> to strip any directory components before
|
||||
/// prepending the GUID prefix, preventing path traversal if the browser supplies a name with
|
||||
/// slashes. Returns a relative path (from <c>wwwroot</c>) suitable for storing in the database.
|
||||
/// slashes. The target subfolder is resolved and confined under <c>wwwroot/uploads/</c> before
|
||||
/// any file system access occurs. Returns a relative path (from <c>wwwroot</c>) suitable for
|
||||
/// storing in the database.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveFileAsync(
|
||||
IFormFile file,
|
||||
@@ -65,7 +68,11 @@ public class FileService : IFileService
|
||||
// Create upload directory if it doesn't exist
|
||||
// NOTE: WebRootPath is read-only on Azure Linux App Service; this service is legacy
|
||||
// and should only be called for on-premises deployments. New uploads use Azure Blob.
|
||||
var uploadPath = Path.Combine(_environment.WebRootPath, "uploads", subfolder);
|
||||
if (!TryResolveUploadSubfolder(subfolder, out var uploadPath, out var relativeSubfolder, out var subfolderError))
|
||||
{
|
||||
return (false, string.Empty, subfolderError);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(uploadPath))
|
||||
{
|
||||
try
|
||||
@@ -93,7 +100,7 @@ public class FileService : IFileService
|
||||
}
|
||||
|
||||
// Return relative path from wwwroot
|
||||
var relativePath = Path.Combine("uploads", subfolder, uniqueFileName).Replace("\\", "/");
|
||||
var relativePath = Path.Combine(UploadsRootFolder, relativeSubfolder, uniqueFileName).Replace("\\", "/");
|
||||
|
||||
_logger.LogInformation("File saved successfully: {FilePath}", relativePath);
|
||||
return (true, relativePath, string.Empty);
|
||||
@@ -108,8 +115,8 @@ public class FileService : IFileService
|
||||
/// <summary>
|
||||
/// Deletes a file given its relative path from <c>wwwroot</c>.
|
||||
/// Returns success if the file does not exist (idempotent) so that callers do not need to check
|
||||
/// existence before calling. The relative path is converted to an absolute path with
|
||||
/// <see cref="Path.Combine"/> rather than string concatenation to prevent directory traversal.
|
||||
/// existence before calling. The relative path is normalized and must remain under
|
||||
/// <c>wwwroot/uploads/</c>; paths outside that root are rejected.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string ErrorMessage)> DeleteFileAsync(string filePath)
|
||||
{
|
||||
@@ -120,7 +127,10 @@ public class FileService : IFileService
|
||||
return (false, "File path is required.");
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError))
|
||||
{
|
||||
return (false, pathError);
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
@@ -142,8 +152,8 @@ public class FileService : IFileService
|
||||
|
||||
/// <summary>
|
||||
/// Reads a file from disk and returns its raw bytes along with a derived MIME content type.
|
||||
/// Intended for serving files that are stored outside <c>wwwroot</c> (or otherwise not directly
|
||||
/// accessible via the static-files middleware) so controllers can stream them as file responses.
|
||||
/// Intended for serving files that are stored under the legacy <c>wwwroot/uploads/</c> path but
|
||||
/// are otherwise not directly exposed through the static-files middleware.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetFileAsync(string filePath)
|
||||
{
|
||||
@@ -154,7 +164,10 @@ public class FileService : IFileService
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError))
|
||||
{
|
||||
return (false, Array.Empty<byte>(), string.Empty, pathError);
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
@@ -175,7 +188,7 @@ public class FileService : IFileService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a file exists at the given <c>wwwroot</c>-relative path without reading it.
|
||||
/// Checks whether a file exists at the given <c>wwwroot/uploads/</c>-relative path without reading it.
|
||||
/// Used by views and controllers to conditionally show download links only when the file is present.
|
||||
/// </summary>
|
||||
public bool FileExists(string filePath)
|
||||
@@ -185,7 +198,11 @@ public class FileService : IFileService
|
||||
return false;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return File.Exists(fullPath);
|
||||
}
|
||||
|
||||
@@ -212,4 +229,96 @@ public class FileService : IFileService
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
private bool TryResolveUploadSubfolder(
|
||||
string subfolder,
|
||||
out string uploadPath,
|
||||
out string relativeSubfolder,
|
||||
out string errorMessage)
|
||||
{
|
||||
uploadPath = string.Empty;
|
||||
relativeSubfolder = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subfolder))
|
||||
{
|
||||
errorMessage = "Upload subfolder is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedSubfolder = subfolder.Replace('\\', '/').Trim('/');
|
||||
var resolvedPath = Path.GetFullPath(
|
||||
Path.Combine(uploadsRoot, normalizedSubfolder.Replace('/', Path.DirectorySeparatorChar)));
|
||||
|
||||
if (!IsWithinDirectory(resolvedPath, uploadsRoot))
|
||||
{
|
||||
errorMessage = "Invalid upload subfolder.";
|
||||
_logger.LogWarning("Rejected upload subfolder outside uploads root: {Subfolder}", subfolder);
|
||||
return false;
|
||||
}
|
||||
|
||||
relativeSubfolder = Path.GetRelativePath(uploadsRoot, resolvedPath).Replace("\\", "/");
|
||||
uploadPath = resolvedPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryResolveLegacyUploadPath(string filePath, out string fullPath, out string errorMessage)
|
||||
{
|
||||
fullPath = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedRelativePath = filePath.Replace('\\', '/').TrimStart('/');
|
||||
if (!normalizedRelativePath.StartsWith($"{UploadsRootFolder}/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errorMessage = "Invalid file path.";
|
||||
_logger.LogWarning("Rejected legacy file path outside uploads root: {FilePath}", filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
var resolvedPath = Path.GetFullPath(
|
||||
Path.Combine(_environment.WebRootPath, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)));
|
||||
|
||||
if (!IsWithinDirectory(resolvedPath, uploadsRoot))
|
||||
{
|
||||
errorMessage = "Invalid file path.";
|
||||
_logger.LogWarning("Rejected path traversal attempt for legacy file path: {FilePath}", filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
fullPath = resolvedPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryGetUploadsRootPath(out string uploadsRoot, out string errorMessage)
|
||||
{
|
||||
uploadsRoot = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_environment.WebRootPath))
|
||||
{
|
||||
errorMessage = "File storage is not available in this environment.";
|
||||
_logger.LogWarning("WebRootPath is not configured for the legacy file service.");
|
||||
return false;
|
||||
}
|
||||
|
||||
uploadsRoot = Path.GetFullPath(Path.Combine(_environment.WebRootPath, UploadsRootFolder));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsWithinDirectory(string candidatePath, string rootPath)
|
||||
{
|
||||
var normalizedRoot = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
return candidatePath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,8 +120,11 @@ public class StorageMigrationService : IStorageMigrationService
|
||||
|
||||
var contentType = GetContentType(Path.GetExtension(fullPath).ToLowerInvariant());
|
||||
|
||||
await using var stream = File.OpenRead(fullPath);
|
||||
var uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType);
|
||||
(bool Success, string ErrorMessage) uploadResult;
|
||||
await using (var stream = File.OpenRead(fullPath))
|
||||
{
|
||||
uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType);
|
||||
}
|
||||
|
||||
if (!uploadResult.Success)
|
||||
{
|
||||
|
||||
@@ -61,6 +61,9 @@ public class ApplicationUser : IdentityUser
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
|
||||
// Passkey enrollment prompt
|
||||
public bool PasskeyPromptDismissed { get; set; } = false;
|
||||
|
||||
// Ban
|
||||
public bool IsBanned { get; set; } = false;
|
||||
public DateTime? BannedAt { get; set; }
|
||||
|
||||
@@ -97,5 +97,19 @@ namespace PowderCoating.Core.Entities
|
||||
|
||||
public virtual Account? RevenueAccount { get; set; }
|
||||
public virtual Account? CogsAccount { get; set; }
|
||||
|
||||
// ── Images ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Blob path of the full-size uploaded image, relative to the catalogimages container.
|
||||
/// Null when no image has been uploaded.
|
||||
/// </summary>
|
||||
public string? ImagePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Blob path of the 200×200 thumbnail generated on upload.
|
||||
/// Null when no image has been uploaded.
|
||||
/// </summary>
|
||||
public string? ThumbnailPath { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace PowderCoating.Core.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores the result of the most recent AI catalog price check run for a company.
|
||||
/// ResultsJson holds the full per-item verdict array serialized as JSON, avoiding the
|
||||
/// need for a wide per-item table while still persisting the report across sessions.
|
||||
/// Only one report is kept per company — each new run overwrites the previous one.
|
||||
/// </summary>
|
||||
public class CatalogPriceCheckReport : BaseEntity
|
||||
{
|
||||
public DateTime RunAt { get; set; }
|
||||
public int ItemsChecked { get; set; }
|
||||
|
||||
/// <summary>JSON-serialized List<CatalogItemPriceVerdict>.</summary>
|
||||
public string ResultsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>Human-readable summary of the operating costs used for this run.</summary>
|
||||
public string OperatingCostsSummary { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,8 @@ public class Company : BaseEntity
|
||||
public bool AiPhotoQuotesEnabled { get; set; } = true;
|
||||
/// <summary>Enables/disables the AI Inventory Assist lookup for this company.</summary>
|
||||
public bool AiInventoryAssistEnabled { get; set; } = true;
|
||||
/// <summary>Enables/disables the AI Catalog Price Check for this company.</summary>
|
||||
public bool AiCatalogPriceCheckEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the billing period the customer selected at registration (or last changed on the Billing page).
|
||||
@@ -86,6 +88,19 @@ public class Company : BaseEntity
|
||||
/// </summary>
|
||||
public bool? AccountingOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Company admin opt-in for SMS notifications. Defaults to false — company admin must
|
||||
/// explicitly accept the SMS terms of service before enabling. Has no effect if the plan
|
||||
/// does not allow SMS or if SmsDisabledByAdmin is true.
|
||||
/// </summary>
|
||||
public bool SmsEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// SuperAdmin force-disable for this company's SMS. When true, no SMS is sent regardless
|
||||
/// of plan or company settings. Use when a company is abusing SMS or requests a full opt-out.
|
||||
/// </summary>
|
||||
public bool SmsDisabledByAdmin { get; set; } = false;
|
||||
|
||||
// Email marketing opt-out (CAN-SPAM compliance for platform broadcast emails)
|
||||
public bool MarketingEmailOptOut { get; set; } = false;
|
||||
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
@@ -86,6 +86,22 @@ public class CompanyPreferences : BaseEntity
|
||||
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
||||
public string? QbMigrationStateJson { get; set; }
|
||||
|
||||
// Guided activation / first-workflow onboarding
|
||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||
public string? OnboardingPath { get; set; }
|
||||
/// <summary>True once the company completes its first guided real workflow.</summary>
|
||||
public bool FirstWorkflowCompleted { get; set; } = false;
|
||||
/// <summary>UTC timestamp of when the first guided workflow was completed.</summary>
|
||||
public DateTime? FirstWorkflowCompletedAt { get; set; }
|
||||
/// <summary>UTC timestamp of the company's first quote creation.</summary>
|
||||
public DateTime? FirstQuoteCreatedAt { get; set; }
|
||||
/// <summary>UTC timestamp of the company's first job creation.</summary>
|
||||
public DateTime? FirstJobCreatedAt { get; set; }
|
||||
/// <summary>UTC timestamp of the company's first invoice creation.</summary>
|
||||
public DateTime? FirstInvoiceCreatedAt { get; set; }
|
||||
/// <summary>UTC timestamp of when the company dismissed guided activation without completing it.</summary>
|
||||
public DateTime? GuidedActivationDismissedAt { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Company Company { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable audit record of a company admin accepting the SMS terms of service.
|
||||
/// One record is written each time a user accepts (including re-accepts after a terms update).
|
||||
/// The most recent record whose <see cref="TermsVersion"/> matches
|
||||
/// <c>AppConstants.SmsTermsVersion</c> is the authoritative acceptance for that company.
|
||||
/// Never soft-deleted — this is a legal audit trail.
|
||||
/// </summary>
|
||||
public class CompanySmsAgreement : BaseEntity
|
||||
{
|
||||
/// <summary>The Identity user ID of the admin who clicked "I Agree".</summary>
|
||||
public string AgreedByUserId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Display name snapshot of the user at the time of agreement (for audit readability after user changes).</summary>
|
||||
public string AgreedByUserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UTC timestamp of acceptance.</summary>
|
||||
public DateTime AgreedAt { get; set; }
|
||||
|
||||
/// <summary>Client IP address at the time of acceptance. Stored for legal/fraud purposes.</summary>
|
||||
public string? IpAddress { get; set; }
|
||||
|
||||
/// <summary>HTTP User-Agent header at the time of acceptance.</summary>
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The version of the SMS terms that was accepted (matches <c>AppConstants.SmsTermsVersion</c>
|
||||
/// at the moment of acceptance). When the platform bumps this version, existing records become
|
||||
/// stale and the company must re-accept.
|
||||
/// </summary>
|
||||
public string TermsVersion { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class InventoryItem : BaseEntity
|
||||
public string? ColorFamilies { get; set; } // Comma-separated primary color families e.g. "Green,Blue"
|
||||
public bool RequiresClearCoat { get; set; } // True if this powder requires a clear coat topcoat
|
||||
public string? SpecPageUrl { get; set; } // Link to manufacturer's product/spec page
|
||||
public string? ImageUrl { get; set; } // Product image URL (sourced from og:image on AI lookup)
|
||||
|
||||
// Sample Panel Tracking (coating category items only)
|
||||
public bool HasSamplePanel { get; set; } = false;
|
||||
|
||||
@@ -55,6 +55,10 @@ public class Job : BaseEntity
|
||||
public int? IntakePartCount { get; set; }
|
||||
public string? IntakeCheckedByUserId { get; set; }
|
||||
|
||||
// Quote snapshot — UpdatedAt of the source quote at the moment this job was created from it.
|
||||
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
||||
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
||||
|
||||
// Rework tracking
|
||||
public bool IsReworkJob { get; set; }
|
||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||
|
||||
@@ -14,4 +14,5 @@ public static class PlatformSettingKeys
|
||||
public const string StripeWebhookRetentionDays = "StripeWebhookRetentionDays";
|
||||
public const string MaxTenants = "MaxTenants";
|
||||
public const string SmsEnabled = "SmsEnabled";
|
||||
public const string AiCatalogPriceCheckEnabled = "AiCatalogPriceCheckEnabled";
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ public class SubscriptionPlanConfig : BaseEntity
|
||||
/// <summary>When true, companies on this plan can use the AI Inventory Assist lookup feature.</summary>
|
||||
public bool AllowAiInventoryAssist { get; set; } = false;
|
||||
|
||||
/// <summary>When true, companies on this plan can run the AI Catalog Price Check (Enterprise only).</summary>
|
||||
public bool AllowAiCatalogPriceCheck { get; set; } = false;
|
||||
|
||||
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
|
||||
public bool AllowSms { get; set; } = false;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Stores a WebAuthn public-key credential (passkey) registered by an application user.
|
||||
/// One row per device per user. Does not inherit BaseEntity — passkeys are identity
|
||||
/// credentials, not business-domain records, and require no soft-delete or company-scoped
|
||||
/// global query filter (the Login flow queries across tenants by credentialId before auth).
|
||||
/// </summary>
|
||||
public class UserPasskey
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>FK to AspNetUsers.Id (GUID string).</summary>
|
||||
public string UserId { get; set; } = default!;
|
||||
|
||||
/// <summary>Stored for display/management queries. NOT used as a query filter.</summary>
|
||||
public int CompanyId { get; set; }
|
||||
|
||||
/// <summary>WebAuthn credential ID — unique identifier for this passkey.</summary>
|
||||
public byte[] CredentialId { get; set; } = default!;
|
||||
|
||||
/// <summary>COSE-encoded public key from the authenticator.</summary>
|
||||
public byte[] PublicKey { get; set; } = default!;
|
||||
|
||||
/// <summary>Opaque user handle sent by the authenticator during login.</summary>
|
||||
public byte[] UserHandle { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Monotonically increasing counter used to detect cloned authenticators.
|
||||
/// Stored as long to avoid SQL Server uint mapping issues; Fido2NetLib uses uint.
|
||||
/// </summary>
|
||||
public long SignCount { get; set; }
|
||||
|
||||
/// <summary>User-supplied or browser-provided friendly name, e.g. "Scott's iPhone".</summary>
|
||||
public string? DeviceFriendlyName { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
}
|
||||
@@ -17,5 +17,6 @@ public enum NotificationType
|
||||
SubscriptionExpiryReminder = 10,
|
||||
SubscriptionExpired = 11,
|
||||
SmsInboundStop = 12,
|
||||
SmsInboundHelp = 13
|
||||
SmsInboundHelp = 13,
|
||||
AdminEmail = 14
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight repository interface for platform-level entities that do not inherit
|
||||
/// <see cref="PowderCoating.Core.Entities.BaseEntity"/> (e.g. Announcement, BannedIp,
|
||||
/// DashboardTip, ReleaseNote). These entities have no CompanyId, no IsDeleted, and no
|
||||
/// soft-delete semantics — so the full IRepository<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);
|
||||
}
|
||||
@@ -28,6 +28,8 @@ public interface ISubscriptionService
|
||||
Task<bool> CanUseAiPhotoQuoteAsync(int companyId);
|
||||
/// <summary>Returns (used this month, monthly max). Max = -1 means unlimited.</summary>
|
||||
Task<(int Used, int Max)> GetAiPhotoQuoteUsageAsync(int companyId);
|
||||
/// <summary>Returns true if the AI Catalog Price Check is enabled for this company (plan gate + per-company flag).</summary>
|
||||
Task<bool> CanUseAiCatalogPriceCheckAsync(int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns days until expiry (negative = days past expiry). Returns null if no end date set.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces.Repositories;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces;
|
||||
|
||||
@@ -8,34 +9,37 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<Company> Companies { get; }
|
||||
IRepository<CompanyOperatingCosts> CompanyOperatingCosts { get; }
|
||||
IRepository<CompanyPreferences> CompanyPreferences { get; }
|
||||
IRepository<CompanySmsAgreement> CompanySmsAgreements { get; }
|
||||
|
||||
// AI Predictions
|
||||
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; }
|
||||
@@ -63,43 +67,52 @@ public interface IUnitOfWork : IDisposable
|
||||
// Product Catalog
|
||||
IRepository<CatalogCategory> CatalogCategories { get; }
|
||||
IRepository<CatalogItem> CatalogItems { get; }
|
||||
IRepository<CatalogPriceCheckReport> CatalogPriceCheckReports { get; }
|
||||
|
||||
// Oven Scheduling
|
||||
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,142 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result record carrying the pre-sliced entity lists and aggregates needed to render the
|
||||
/// operator dashboard index view. The read service does the heavy SQL filtering so the
|
||||
/// controller can focus on lightweight DTO projection and view assembly.
|
||||
/// </summary>
|
||||
public record DashboardIndexData(
|
||||
int ActiveJobsCount,
|
||||
int TodaysJobsCount,
|
||||
List<Job> TodaysJobs,
|
||||
int OverdueJobsCount,
|
||||
List<Job> OverdueJobs,
|
||||
List<Job> InProgressJobs,
|
||||
int TodaysAppointmentsCount,
|
||||
List<Appointment> TodaysAppointments,
|
||||
int LowStockCount,
|
||||
List<InventoryItem> LowStockItems,
|
||||
int PendingMaintenanceCount,
|
||||
List<MaintenanceRecord> UpcomingMaintenance,
|
||||
int PendingQuotesCount,
|
||||
decimal PendingQuoteValue,
|
||||
List<Quote> PendingQuotes,
|
||||
List<Quote> ExpiringQuotes,
|
||||
int ActiveCustomersCount,
|
||||
decimal MonthlyRevenue,
|
||||
decimal OutstandingAr,
|
||||
decimal InvoicedThisMonth,
|
||||
decimal CollectedThisMonth,
|
||||
int OverdueInvoicesCount,
|
||||
decimal OverdueInvoicesAmount,
|
||||
DashboardArAgingData ArAging,
|
||||
List<Invoice> OverdueInvoices,
|
||||
List<Payment> RecentPayments,
|
||||
List<Quote> RecentQuotes,
|
||||
List<Job> RecentJobs,
|
||||
List<Equipment> EquipmentAlerts,
|
||||
List<DashboardPowderOrderLineData> PowderOrdersNeeded,
|
||||
List<DashboardPowderOrderLineData> PowderOrdersPlaced,
|
||||
int BillsDueCount,
|
||||
decimal BillsDueAmount,
|
||||
List<Bill> BillsDue,
|
||||
string? TipOfTheDay
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// AR aging bucket totals used by the dashboard receivables summary.
|
||||
/// </summary>
|
||||
public record DashboardArAgingData(
|
||||
decimal Current,
|
||||
decimal Days1To30,
|
||||
decimal Days31To60,
|
||||
decimal Days61To90,
|
||||
decimal DaysOver90
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Flattened powder-order line data so the controller does not need to materialize full job/item/coat graphs.
|
||||
/// </summary>
|
||||
public record DashboardPowderOrderLineData(
|
||||
int CoatId,
|
||||
int JobId,
|
||||
string JobNumber,
|
||||
string CustomerName,
|
||||
string CoatName,
|
||||
string? ColorName,
|
||||
string? ColorCode,
|
||||
string? Finish,
|
||||
string? SKU,
|
||||
decimal LbsToOrder,
|
||||
decimal? CostPerLb,
|
||||
DateTime? OrderedAt,
|
||||
bool HasInventoryItem,
|
||||
int? VendorId,
|
||||
string? VendorName,
|
||||
string? VendorPhone,
|
||||
string? VendorEmail
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated data for the SuperAdmin dashboard.
|
||||
/// </summary>
|
||||
public record SuperAdminDashboardData(
|
||||
int TotalCompanies,
|
||||
int ActiveCompanies,
|
||||
int InactiveCompanies,
|
||||
int TotalUsers,
|
||||
int ActiveSubscriptions,
|
||||
int GracePeriodCount,
|
||||
int ExpiredCount,
|
||||
Dictionary<int, DashboardPlanDistributionData> PlanDistribution,
|
||||
List<SuperAdminCompanyAlertData> CompanyAlerts,
|
||||
List<SuperAdminRecentCompanyData> RecentCompanies
|
||||
);
|
||||
|
||||
public record DashboardPlanDistributionData(
|
||||
string DisplayName,
|
||||
int Count
|
||||
);
|
||||
|
||||
public record SuperAdminCompanyAlertData(
|
||||
int Id,
|
||||
string CompanyName,
|
||||
int Plan,
|
||||
string PlanDisplayName,
|
||||
SubscriptionStatus Status,
|
||||
DateTime? SubscriptionEndDate,
|
||||
int DaysOverdue,
|
||||
bool IsActive
|
||||
);
|
||||
|
||||
public record SuperAdminRecentCompanyData(
|
||||
int Id,
|
||||
string CompanyName,
|
||||
int Plan,
|
||||
string PlanDisplayName,
|
||||
SubscriptionStatus Status,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
/// <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>Fetches all data needed to render the SuperAdmin dashboard.</summary>
|
||||
Task<SuperAdminDashboardData> GetSuperAdminDashboardDataAsync(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;
|
||||
@@ -143,6 +143,9 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
/// <summary>Tenant company records. Soft-delete only filter applies (no CompanyId filter — SuperAdmin manages all companies).</summary>
|
||||
public DbSet<Company> Companies { get; set; }
|
||||
|
||||
/// <summary>Immutable audit trail of SMS terms-of-service acceptances per company. Tenant-filtered by CompanyId; never soft-deleted.</summary>
|
||||
public DbSet<CompanySmsAgreement> CompanySmsAgreements { get; set; }
|
||||
|
||||
/// <summary>AI quote-item predictions; tenant-filtered. Both <c>QuoteItem</c> and <c>JobItem</c> share a single prediction record via nullable FK (no duplication on quote→job conversion).</summary>
|
||||
public DbSet<AiItemPrediction> AiItemPredictions { get; set; }
|
||||
|
||||
@@ -260,6 +263,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
public DbSet<CatalogCategory> CatalogCategories { get; set; }
|
||||
/// <summary>Pre-priced service catalog items that can be added to quotes/jobs; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CatalogItem> CatalogItems { get; set; }
|
||||
/// <summary>Most-recent AI price-check report per company; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CatalogPriceCheckReport> CatalogPriceCheckReports { get; set; }
|
||||
|
||||
// Notifications
|
||||
/// <summary>Log of all outbound notifications (email, SMS, in-app) for audit and retry; tenant-filtered with soft delete.</summary>
|
||||
@@ -385,6 +390,13 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
/// </summary>
|
||||
public DbSet<PendingRegistrationSession> PendingRegistrationSessions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// WebAuthn passkey credentials registered by users for biometric login (Face ID, fingerprint).
|
||||
/// No global query filter — the login flow queries by credentialId before authentication,
|
||||
/// requiring cross-tenant lookup. Per-user isolation is enforced in the controller.
|
||||
/// </summary>
|
||||
public DbSet<UserPasskey> UserPasskeys { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Configures the EF Core model: applies entity type configurations from the assembly,
|
||||
/// registers global query filters, defines relationships, adds performance indexes, and seeds
|
||||
@@ -501,6 +513,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CatalogItem>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CatalogPriceCheckReport>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
modelBuilder.Entity<Appointment>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
@@ -792,6 +806,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||
property.SetColumnType("decimal(18,2)");
|
||||
}
|
||||
|
||||
// UserPasskey: unique index on CredentialId (WebAuthn requires global uniqueness)
|
||||
modelBuilder.Entity<UserPasskey>()
|
||||
.HasIndex(p => p.CredentialId)
|
||||
.IsUnique();
|
||||
|
||||
// Configure relationships
|
||||
ConfigureRelationships(modelBuilder);
|
||||
|
||||
|
||||
@@ -912,17 +912,6 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new NotificationTemplate
|
||||
{
|
||||
NotificationType = NotificationType.JobReadyForPickup,
|
||||
Channel = NotificationChannel.Sms,
|
||||
DisplayName = "Job Ready for Pickup (SMS)",
|
||||
Subject = null,
|
||||
Body = "{{companyName}}: Job {{jobNumber}} is ready for pickup! Reply STOP to opt out.",
|
||||
IsActive = true,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new NotificationTemplate
|
||||
{
|
||||
NotificationType = NotificationType.JobCompleted,
|
||||
Channel = NotificationChannel.Email,
|
||||
@@ -1204,6 +1193,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Enable AllowSms for Pro and Enterprise plans if not already set
|
||||
var smsPlansToFix = await context.SubscriptionPlanConfigs.IgnoreQueryFilters()
|
||||
.Where(c => (c.Plan == 1 || c.Plan == 2) && !c.AllowSms)
|
||||
.ToListAsync();
|
||||
if (smsPlansToFix.Count > 0)
|
||||
{
|
||||
foreach (var row in smsPlansToFix)
|
||||
row.AllowSms = true;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Only seed if table is empty
|
||||
if (await context.SubscriptionPlanConfigs.IgnoreQueryFilters().AnyAsync())
|
||||
return;
|
||||
@@ -1256,6 +1256,7 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
||||
MaxCatalogItems = 500,
|
||||
MonthlyPrice = 79m,
|
||||
AnnualPrice = 790m,
|
||||
AllowSms = true,
|
||||
IsActive = true,
|
||||
SortOrder = 3,
|
||||
CompanyId = 0,
|
||||
@@ -1273,6 +1274,7 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
||||
MaxCatalogItems = -1,
|
||||
MonthlyPrice = 199m,
|
||||
AnnualPrice = 1990m,
|
||||
AllowSms = true,
|
||||
IsActive = true,
|
||||
SortOrder = 4,
|
||||
CompanyId = 0,
|
||||
|
||||
Generated
+9197
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCatalogItemImages : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ImagePath",
|
||||
table: "CatalogItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ThumbnailPath",
|
||||
table: "CatalogItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5147));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5155));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5156));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ImagePath",
|
||||
table: "CatalogItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ThumbnailPath",
|
||||
table: "CatalogItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7155));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7162));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7164));
|
||||
}
|
||||
}
|
||||
}
|
||||
+9244
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserPasskeys : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserPasskeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
UserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CredentialId = table.Column<byte[]>(type: "varbinary(900)", nullable: false),
|
||||
PublicKey = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
|
||||
UserHandle = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
|
||||
SignCount = table.Column<long>(type: "bigint", nullable: false),
|
||||
DeviceFriendlyName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
LastUsedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserPasskeys", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserPasskeys_CredentialId",
|
||||
table: "UserPasskeys",
|
||||
column: "CredentialId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserPasskeys");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5147));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5155));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5156));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9295
File diff suppressed because it is too large
Load Diff
+88
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCatalogPriceCheckReport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CatalogPriceCheckReports",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
RunAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
ItemsChecked = table.Column<int>(type: "int", nullable: false),
|
||||
ResultsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
OperatingCostsSummary = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CatalogPriceCheckReports", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6987));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6993));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6994));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CatalogPriceCheckReports");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9301
File diff suppressed because it is too large
Load Diff
+97
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAiCatalogPriceCheckGating : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowAiCatalogPriceCheck",
|
||||
table: "SubscriptionPlanConfigs",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AiCatalogPriceCheckEnabled",
|
||||
table: "Companies",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
// Use raw SQL so we don't collide with an existing row — ID 9 may already be
|
||||
// taken in environments where settings were added outside of migrations.
|
||||
migrationBuilder.Sql("""
|
||||
IF NOT EXISTS (SELECT 1 FROM [PlatformSettings] WHERE [Key] = N'AiCatalogPriceCheckEnabled')
|
||||
BEGIN
|
||||
INSERT INTO [PlatformSettings] ([Key], [Value], [Label], [Description], [GroupName])
|
||||
VALUES (N'AiCatalogPriceCheckEnabled', N'true', N'AI Catalog Price Check Enabled',
|
||||
N'When true (default), the AI Catalog Price Check feature is available to companies on qualifying plans. Set to false to disable it platform-wide.',
|
||||
N'AI Features');
|
||||
END
|
||||
""");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5012));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5018));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5020));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DELETE FROM [PlatformSettings] WHERE [Key] = N'AiCatalogPriceCheckEnabled'");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowAiCatalogPriceCheck",
|
||||
table: "SubscriptionPlanConfigs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AiCatalogPriceCheckEnabled",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6987));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6993));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6994));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9304
File diff suppressed because it is too large
Load Diff
+72
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPasskeyPromptDismissed : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PasskeyPromptDismissed",
|
||||
table: "AspNetUsers",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PasskeyPromptDismissed",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5012));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5018));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 12, 26, 21, 727, DateTimeKind.Utc).AddTicks(5020));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9325
File diff suppressed because it is too large
Load Diff
+132
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGuidedActivationFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "FirstInvoiceCreatedAt",
|
||||
table: "CompanyPreferences",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "FirstJobCreatedAt",
|
||||
table: "CompanyPreferences",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "FirstQuoteCreatedAt",
|
||||
table: "CompanyPreferences",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "FirstWorkflowCompleted",
|
||||
table: "CompanyPreferences",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "FirstWorkflowCompletedAt",
|
||||
table: "CompanyPreferences",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GuidedActivationDismissedAt",
|
||||
table: "CompanyPreferences",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "OnboardingPath",
|
||||
table: "CompanyPreferences",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FirstInvoiceCreatedAt",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FirstJobCreatedAt",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FirstQuoteCreatedAt",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FirstWorkflowCompleted",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FirstWorkflowCompletedAt",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GuidedActivationDismissedAt",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OnboardingPath",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9328
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobQuoteSnapshotUpdatedAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "QuoteSnapshotUpdatedAt",
|
||||
table: "Jobs",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4877));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4884));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4886));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuoteSnapshotUpdatedAt",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9331
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInventoryItemImageUrl : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ImageUrl",
|
||||
table: "InventoryItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ImageUrl",
|
||||
table: "InventoryItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4877));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4884));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4886));
|
||||
}
|
||||
}
|
||||
}
|
||||
+9340
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSmsGating : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowSms",
|
||||
table: "SubscriptionPlanConfigs",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SmsDisabledByAdmin",
|
||||
table: "Companies",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SmsEnabled",
|
||||
table: "Companies",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(523));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(529));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(531));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowSms",
|
||||
table: "SubscriptionPlanConfigs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SmsDisabledByAdmin",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SmsEnabled",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9398
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCompanySmsAgreement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CompanySmsAgreements",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
AgreedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
AgreedByUserName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
AgreedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
IpAddress = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UserAgent = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
TermsVersion = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CompanySmsAgreements", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CompanySmsAgreements");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(523));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(529));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(531));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -555,6 +555,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("PasskeyPromptDismissed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -1416,6 +1419,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("DisplayOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("InventoryItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -1438,6 +1444,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("SKU")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -1459,6 +1468,57 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("CatalogItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CatalogPriceCheckReport", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("ItemsChecked")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("OperatingCostsSummary")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ResultsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("RunAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CatalogPriceCheckReports");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Company", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1473,6 +1533,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Address")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("AiCatalogPriceCheckEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("AiInventoryAssistEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -1579,6 +1642,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("SmsDisabledByAdmin")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("SmsEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("State")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -1906,6 +1975,24 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("EmailNotificationsEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("FirstInvoiceCreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("FirstJobCreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("FirstQuoteCreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("FirstWorkflowCompleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("FirstWorkflowCompletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime?>("GuidedActivationDismissedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("InAccentColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -1954,6 +2041,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("NotifyOnQuoteApproval")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("OnboardingPath")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PaymentReminderDays")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -2032,6 +2122,64 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("CompanyPreferences");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CompanySmsAgreement", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("AgreedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("AgreedByUserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("AgreedByUserName")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("TermsVersion")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("CompanySmsAgreements");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.ContactSubmission", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -3097,6 +3245,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("HasSamplePanel")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("ImageUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("InventoryAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -3648,6 +3799,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("QuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("QuoteSnapshotUpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("QuotedPrice")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -5776,7 +5930,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7155),
|
||||
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -5787,7 +5941,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7162),
|
||||
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -5798,7 +5952,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7164),
|
||||
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7126,6 +7280,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("AllowAccounting")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("AllowAiCatalogPriceCheck")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("AllowAiInventoryAssist")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -7135,6 +7292,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("AllowOnlinePayments")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("AllowSms")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal>("AnnualPrice")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -7249,6 +7409,53 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("TermsAcceptances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<byte[]>("CredentialId")
|
||||
.IsRequired()
|
||||
.HasColumnType("varbinary(900)");
|
||||
|
||||
b.Property<string>("DeviceFriendlyName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<byte[]>("PublicKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("varbinary(max)");
|
||||
|
||||
b.Property<long>("SignCount")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<byte[]>("UserHandle")
|
||||
.IsRequired()
|
||||
.HasColumnType("varbinary(max)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CredentialId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserPasskeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageReference Include="Stripe.net" Version="50.4.1" />
|
||||
<PackageReference Include="Twilio" Version="7.14.3" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user