Merge dev into master

- AI Catalog Price Check (Haiku model, rate limiting, progress bar, quarterly limit)
- Three-layer feature gating for AI Catalog Price Check (platform/plan/company)
- Passkey biometric login improvements (enrollment prompt, RPID fix, dismiss option)
- Company admin navigation consolidation (Subscription & Features button)
- Unit tests for 9 new services/controllers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 10:35:29 -04:00
98 changed files with 55164 additions and 142 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.11",
"commands": [
"dotnet-ef"
]
}
}
}
+100
View File
@@ -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
View File
@@ -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()
}
}
}
+6
View File
@@ -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
+2
View File
@@ -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
+50
View File
@@ -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
@@ -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,6 +158,7 @@ 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)
@@ -24,6 +24,7 @@ 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 IsActive { get; set; }
public int SortOrder { get; set; }
}
@@ -70,6 +71,7 @@ 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 IsActive { get; set; }
}
@@ -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,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);
}
@@ -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>();
@@ -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;
}
}
}
@@ -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&lt;CatalogItemPriceVerdict&gt;.</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).
@@ -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,9 @@ 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;
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; }
}
@@ -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.
@@ -63,6 +63,7 @@ public interface IUnitOfWork : IDisposable
// Product Catalog
IRepository<CatalogCategory> CatalogCategories { get; }
IRepository<CatalogItem> CatalogItems { get; }
IRepository<CatalogPriceCheckReport> CatalogPriceCheckReports { get; }
// Oven Scheduling
IRepository<OvenBatch> OvenBatches { get; }
@@ -260,6 +260,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 +387,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 +510,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,9 +803,14 @@ 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);
// Seed initial data
SeedInitialData(modelBuilder);
}
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));
}
}
}
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));
}
}
}
@@ -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));
}
}
}
@@ -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));
}
}
}
@@ -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));
}
}
}
@@ -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");
@@ -5776,7 +5839,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, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5787,7 +5850,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, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5798,7 +5861,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, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7126,6 +7189,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("AllowAccounting")
.HasColumnType("bit");
b.Property<bool>("AllowAiCatalogPriceCheck")
.HasColumnType("bit");
b.Property<bool>("AllowAiInventoryAssist")
.HasColumnType("bit");
@@ -7249,6 +7315,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>
@@ -84,6 +84,7 @@ public class UnitOfWork : IUnitOfWork
// Product Catalog
private IRepository<CatalogCategory>? _catalogCategories;
private IRepository<CatalogItem>? _catalogItems;
private IRepository<CatalogPriceCheckReport>? _catalogPriceCheckReports;
// Notifications
private IRepository<NotificationLog>? _notificationLogs;
@@ -344,6 +345,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<CatalogItem> CatalogItems =>
_catalogItems ??= new Repository<CatalogItem>(_context);
/// <summary>Repository for <see cref="CatalogPriceCheckReport"/> AI price-check results archived per company.</summary>
public IRepository<CatalogPriceCheckReport> CatalogPriceCheckReports =>
_catalogPriceCheckReports ??= new Repository<CatalogPriceCheckReport>(_context);
// Notifications
/// <summary>Repository for <see cref="NotificationLog"/> outbound notification audit records; tenant-filtered with soft delete.</summary>
public IRepository<NotificationLog> NotificationLogs =>
@@ -0,0 +1,328 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Sends catalog items to Claude in batches of 25 and collects per-item price verdicts.
/// Each batch produces one Claude call so large catalogs stay within the model's context
/// limits. Results across all batches are merged into a single flat list before returning.
/// </summary>
public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService
{
private readonly IConfiguration _config;
private readonly ILogger<AiCatalogPriceCheckService> _logger;
private const string Model = "claude-haiku-4-5-20251001";
private const int BatchSize = 25;
private const int MaxConcurrentBatches = 1; // sequential avoids bursting past the 8k output TPM limit
private const int RateLimitRetrySeconds = 65;
private const int MinBatchIntervalSeconds = 20; // proactive pacing: ~3 batches/min × ~2k tokens = ~6k TPM, under the 8k limit
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
public AiCatalogPriceCheckService(IConfiguration config, ILogger<AiCatalogPriceCheckService> logger)
{
_config = config;
_logger = logger;
}
private string? GetApiKey()
{
var key = _config["AI:Anthropic:ApiKey"];
return string.IsNullOrWhiteSpace(key) || key.StartsWith("your-") ? null : key;
}
/// <summary>
/// Extracts a JSON array from Claude's response, handling three common failure modes:
/// (1) ```json ... ``` fences wrapping the array,
/// (2) prose text before or after the JSON array,
/// (3) truncated responses where the closing ] is missing — in that case we close any
/// open string and append ]} to produce a parseable (though incomplete) array so
/// we recover whatever items Claude did finish.
/// </summary>
private static string ExtractJsonArray(string text)
{
var trimmed = text.Trim();
// Strip code fences
if (trimmed.StartsWith("```"))
{
var firstNewline = trimmed.IndexOf('\n');
if (firstNewline >= 0) trimmed = trimmed[(firstNewline + 1)..];
if (trimmed.EndsWith("```")) trimmed = trimmed[..^3];
trimmed = trimmed.Trim();
}
// Find the outermost [ ... ] even when Claude adds prose around it
var arrayStart = trimmed.IndexOf('[');
if (arrayStart < 0) return "[]";
trimmed = trimmed[arrayStart..];
var arrayEnd = trimmed.LastIndexOf(']');
if (arrayEnd >= 0)
return trimmed[..(arrayEnd + 1)];
// No closing bracket — response was truncated. Patch it so we can recover
// whatever complete objects Claude did return.
// Strategy: find the last complete }, and close the array after it.
var lastComplete = trimmed.LastIndexOf("},");
if (lastComplete < 0) lastComplete = trimmed.LastIndexOf('}');
if (lastComplete >= 0)
return trimmed[..(lastComplete + 1)] + "]";
return "[]";
}
/// <summary>
/// Sends a message to Claude with up to 3 attempts. On a rate-limit 429, waits
/// RateLimitRetrySeconds before retrying so the per-minute token window can reset.
/// </summary>
private async Task<MessageResponse> SendAsync(AnthropicClient client, MessageParameters parameters)
{
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
return await client.Messages.GetClaudeMessageAsync(parameters, cts.Token);
}
catch (HttpRequestException ex) when (attempt < maxAttempts && ex.Message.Contains("rate_limit_error"))
{
_logger.LogWarning("Rate limit hit (attempt {Attempt}/{Max}), waiting {Seconds}s before retry",
attempt, maxAttempts, RateLimitRetrySeconds);
await Task.Delay(TimeSpan.FromSeconds(RateLimitRetrySeconds));
}
}
// Final attempt — let any exception propagate to the batch error handler
using var finalCts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
return await client.Messages.GetClaudeMessageAsync(parameters, finalCts.Token);
}
/// <inheritdoc/>
public async Task<List<CatalogItemPriceVerdict>> AnalyzeAsync(
List<CatalogItemForPriceCheck> items,
ShopOperatingCostSummary costs,
CancellationToken cancellationToken = default)
{
var apiKey = GetApiKey();
if (apiKey == null)
{
_logger.LogWarning("AI Catalog Price Check called but Anthropic API key is not configured.");
return new List<CatalogItemPriceVerdict>();
}
var client = new AnthropicClient(apiKey);
var systemPrompt = BuildSystemPrompt(costs);
// Split into independent batches upfront
var batches = Enumerable.Range(0, (int)Math.Ceiling(items.Count / (double)BatchSize))
.Select(i => items.Skip(i * BatchSize).Take(BatchSize).ToList())
.ToList();
// Run up to MaxConcurrentBatches in parallel. Each batch is an independent API call
// with its own fresh MessageParameters — no shared state, no growing context.
var semaphore = new SemaphoreSlim(MaxConcurrentBatches, MaxConcurrentBatches);
var batchTasks = batches.Select(async (batch, index) =>
{
await semaphore.WaitAsync(cancellationToken);
try
{
_logger.LogInformation("Starting price check batch {Index}/{Total} ({Count} items)",
index + 1, batches.Count, batch.Count);
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = await AnalyzeBatchAsync(client, systemPrompt, batch);
// Pace output token rate: hold the slot until MinBatchIntervalSeconds has elapsed
// so we stay under the per-minute output token limit without relying solely on retries.
var pad = (int)(MinBatchIntervalSeconds * 1000 - sw.ElapsedMilliseconds);
if (pad > 0) await Task.Delay(pad, cancellationToken);
return result;
}
finally
{
semaphore.Release();
}
}).ToList();
var batchResults = await Task.WhenAll(batchTasks);
// Preserve original catalog order
return batches.Zip(batchResults, (batch, results) => results)
.SelectMany(r => r)
.ToList();
}
private async Task<List<CatalogItemPriceVerdict>> AnalyzeBatchAsync(
AnthropicClient client,
string systemPrompt,
List<CatalogItemForPriceCheck> batch)
{
var userPrompt = BuildUserPrompt(batch);
var parameters = new MessageParameters
{
Model = Model,
MaxTokens = 8192,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new() { Role = RoleType.User, Content = new List<ContentBase> { new TextContent { Text = userPrompt } } }
}
};
var raw = string.Empty;
try
{
var response = await SendAsync(client, parameters);
raw = response.Content.OfType<TextContent>().FirstOrDefault()?.Text ?? "[]";
var json = ExtractJsonArray(raw);
var claudeItems = JsonSerializer.Deserialize<List<ClaudePriceCheckItem>>(json, JsonOpts) ?? new();
return claudeItems.Select(ci =>
{
var source = batch.FirstOrDefault(b => b.Id == ci.catalogItemId);
return new CatalogItemPriceVerdict
{
CatalogItemId = ci.catalogItemId,
Name = source?.Name ?? $"Item #{ci.catalogItemId}",
CurrentPrice = source?.CurrentPrice ?? 0,
Assumptions = ci.assumptions,
EstimatedSqFtMin = ci.estimatedSqFtMin,
EstimatedSqFtMax = ci.estimatedSqFtMax,
EstimatedMinutesMin = ci.estimatedMinutesMin,
EstimatedMinutesMax = ci.estimatedMinutesMax,
CostFloor = ci.costFloor,
Verdict = ci.verdict,
SuggestedPriceMin = ci.suggestedPriceMin,
SuggestedPriceMax = ci.suggestedPriceMax,
Confidence = ci.confidence,
Reasoning = ci.reasoning
};
}).ToList();
}
catch (Exception ex)
{
var preview = raw.Length > 300 ? raw[..300] + "…" : raw;
_logger.LogError(ex,
"AI price check batch failed for items [{ItemIds}]. Raw response preview: {RawPreview}",
string.Join(", ", batch.Select(b => b.Id)), preview);
return batch.Select(item => new CatalogItemPriceVerdict
{
CatalogItemId = item.Id,
Name = item.Name,
CurrentPrice = item.CurrentPrice,
Verdict = "ok",
Confidence = "low",
Assumptions = "Analysis unavailable for this item.",
Reasoning = "An error occurred during analysis. Please re-run the price check."
}).ToList();
}
}
private static string BuildSystemPrompt(ShopOperatingCostSummary costs)
{
var sb = new StringBuilder();
sb.AppendLine("You are a pricing consultant for a powder coating business. Your job is to review catalog items and flag potential pricing problems against the shop's actual operating costs.");
sb.AppendLine();
sb.AppendLine("## Shop Operating Costs");
sb.AppendLine($"- Labor rate: ${costs.LaborRatePerHour:F2}/hr");
sb.AppendLine($"- Oven operating cost: ${costs.OvenCostPerHour:F2}/hr");
sb.AppendLine($"- Sandblaster cost: ${costs.SandblasterCostPerHour:F2}/hr");
sb.AppendLine($"- Coating booth cost: ${costs.CoatingBoothCostPerHour:F2}/hr");
sb.AppendLine($"- Powder material cost: ${costs.PowderCostPerSqFt:F2}/sqft");
sb.AppendLine($"- Shop supplies surcharge: {costs.ShopSuppliesRatePercent:F1}%");
if (costs.PricingMode == "margin")
sb.AppendLine($"- Target gross margin: {costs.MarkupOrMarginPercent:F1}%");
else
sb.AppendLine($"- Markup on material: {costs.MarkupOrMarginPercent:F1}%");
if (costs.ShopMinimumCharge > 0)
sb.AppendLine($"- Shop minimum charge: ${costs.ShopMinimumCharge:F2}");
if (!string.IsNullOrWhiteSpace(costs.AiContextProfile))
{
sb.AppendLine();
sb.AppendLine("## Shop Profile");
sb.AppendLine(costs.AiContextProfile);
}
sb.AppendLine();
sb.AppendLine("## Instructions");
sb.AppendLine("For each item, use industry knowledge to estimate a plausible surface area and processing time. Then compute a cost floor = (labor + equipment + material) using the shop's rates above. Compare the cost floor to the item's current price and return a verdict.");
sb.AppendLine();
sb.AppendLine("Verdict values:");
sb.AppendLine("- \"below-cost\" — price is at or below cost floor (the shop loses money)");
sb.AppendLine("- \"low\" — price is above cost floor but margin is thin (< the shop's target margin)");
sb.AppendLine("- \"high\" — price appears significantly above comparable market rates (risk of losing work)");
sb.AppendLine("- \"ok\" — price is within a reasonable range");
sb.AppendLine();
sb.AppendLine("Confidence values:");
sb.AppendLine("- \"high\" — item name clearly identifies part type and standard dimensions");
sb.AppendLine("- \"medium\" — reasonable assumptions were possible");
sb.AppendLine("- \"low\" — item is too vague to estimate reliably (e.g., 'Custom Part', 'Job Special')");
sb.AppendLine();
sb.AppendLine("The \"category\" field contains the full path, e.g. \"Cerakote > Firearms\" or \"Powder Coat > Wheels\". Use this to determine the coating process — Cerakote items have a very different cost profile than standard powder coat (different equipment, cure times, and market rates). Price accordingly.");
sb.AppendLine();
sb.AppendLine("If the item already has an ApproximateArea or EstimatedMinutes, use those instead of guessing.");
sb.AppendLine();
sb.AppendLine("IMPORTANT: Keep responses concise to avoid truncation. Limit assumptions to 20 words max. Limit reasoning to 25 words max.");
sb.AppendLine();
sb.AppendLine("Return ONLY a JSON array — no prose, no markdown fences, nothing before or after the '['. Use this exact schema for each element:");
sb.AppendLine(@"{
""catalogItemId"": <int>,
""assumptions"": ""<≤20 words>"",
""estimatedSqFtMin"": <decimal>,
""estimatedSqFtMax"": <decimal>,
""estimatedMinutesMin"": <int>,
""estimatedMinutesMax"": <int>,
""costFloor"": <decimal>,
""verdict"": ""ok|low|high|below-cost"",
""suggestedPriceMin"": <decimal>,
""suggestedPriceMax"": <decimal>,
""confidence"": ""high|medium|low"",
""reasoning"": ""<≤25 words>""
}");
return sb.ToString();
}
// Local schema — mirrors the JSON shape Claude is asked to return. Kept private to
// the Infrastructure layer because it's a transport detail, not a domain concept.
private sealed class ClaudePriceCheckItem
{
public int catalogItemId { get; set; }
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; }
public decimal costFloor { get; set; }
public string verdict { get; set; } = "ok";
public decimal suggestedPriceMin { get; set; }
public decimal suggestedPriceMax { get; set; }
public string confidence { get; set; } = "medium";
public string reasoning { get; set; } = string.Empty;
}
private static string BuildUserPrompt(List<CatalogItemForPriceCheck> batch)
{
var itemsJson = JsonSerializer.Serialize(batch.Select(item => new
{
catalogItemId = item.Id,
name = item.Name,
category = item.CategoryName,
currentPrice = item.CurrentPrice,
approximateAreaSqFt = item.ApproximateAreaSqFt,
estimatedMinutes = item.EstimatedMinutes,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking
}), new JsonSerializerOptions { WriteIndented = false });
return $"Analyze these {batch.Count} catalog items and return the JSON verdict array:\n{itemsJson}";
}
}
@@ -41,6 +41,17 @@ public class PlatformSettingsService : IPlatformSettingsService
return setting?.Value;
}
/// <summary>
/// Reads a platform setting as a boolean. Returns <paramref name="defaultValue"/> when
/// the key is missing or the stored value cannot be parsed as a boolean.
/// </summary>
public async Task<bool> GetBoolAsync(string key, bool defaultValue = false)
{
var value = await GetAsync(key);
if (value == null) return defaultValue;
return bool.TryParse(value, out var result) ? result : defaultValue;
}
/// <summary>
/// Creates or updates the platform setting identified by <paramref name="key"/>.
/// Records <paramref name="updatedBy"/> (typically the SuperAdmin's username) and
@@ -316,13 +316,26 @@ public class SubscriptionService : ISubscriptionService
}
/// <summary>
/// Returns <c>true</c> if the company can submit another AI Photo Quote analysis this month.
/// Three gates are evaluated in sequence, stopping at the first failure:
/// (1) plan-level <c>AllowAiPhotoQuotes</c> flag in <see cref="SubscriptionPlanConfig"/>,
/// (2) company-level <c>AiPhotoQuotesEnabled</c> toggle (SuperAdmin override),
/// (3) monthly usage quota from <see cref="GetAiPhotoQuoteUsageAsync"/>.
/// Comped companies bypass all three gates.
/// Returns <c>true</c> if the company can run an AI Catalog Price Check.
/// The company-level <c>AiCatalogPriceCheckEnabled</c> flag is checked first as an
/// explicit SuperAdmin override — enabling it grants access regardless of plan tier,
/// useful for granting individual companies on lower plans. If the override is not set,
/// access falls back to the plan-level <c>AllowAiCatalogPriceCheck</c> flag.
/// Comped companies bypass all gates.
/// </summary>
public async Task<bool> CanUseAiCatalogPriceCheckAsync(int companyId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
if (company == null) return false;
if (company.IsComped) return true;
// Company toggle is an explicit override — grants access regardless of plan
if (company.AiCatalogPriceCheckEnabled) return true;
// Fall back to plan-level gate
return config != null && config.AllowAiCatalogPriceCheck;
}
public async Task<bool> CanUseAiPhotoQuoteAsync(int companyId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
@@ -210,7 +210,14 @@
</div>
}
@{
var _origReturn = Request.Query["ReturnUrl"].FirstOrDefault() ?? "/";
var _enrollUrl = string.IsNullOrEmpty(_origReturn) || _origReturn == "/"
? "/Passkey/EnrollPrompt"
: "/Passkey/EnrollPrompt?returnUrl=" + Uri.EscapeDataString(_origReturn);
}
<form id="account" method="post">
<input type="hidden" name="ReturnUrl" value="@_enrollUrl" />
<div asp-validation-summary="ModelOnly" class="alert alert-danger py-2 mb-3" role="alert" style="display:@(ViewContext.ViewData.ModelState.IsValid ? "none" : "block")"></div>
<div class="mb-3">
@@ -247,6 +254,17 @@
</div>
</form>
<!-- Passkey / Biometric login — shown only if browser supports WebAuthn -->
<div class="passkey-login-section">
<div class="auth-divider"><span>or</span></div>
<div class="d-grid mb-2">
<button id="passkey-login-btn" type="button" class="btn btn-outline-secondary btn-lg d-flex align-items-center justify-content-center gap-2">
<i class="bi bi-fingerprint"></i> Use Face ID / Biometric
</button>
</div>
<p id="passkey-error" class="text-danger small text-center d-none mb-0"></p>
</div>
@if (Model.SignupOpen)
{
<div class="auth-divider"><span>or</span></div>
@@ -269,17 +287,6 @@
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
document.getElementById('togglePw').addEventListener('click', function () {
var input = document.getElementById('passwordInput');
var icon = document.getElementById('togglePwIcon');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
});
</script>
<script src="~/js/login-toggle-pw.js"></script>
<script src="~/js/passkey.js"></script>
}
@@ -4,14 +4,17 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Identity;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.DTOs.Catalog;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
namespace PowderCoating.Web.Controllers
@@ -34,6 +37,9 @@ namespace PowderCoating.Web.Controllers
private readonly ITenantContext _tenantContext;
private readonly IMeasurementConversionService _measurementService;
private readonly ISubscriptionService _subscriptionService;
private readonly ICatalogImageService _catalogImageService;
private readonly IAiCatalogPriceCheckService _priceCheckService;
private readonly IPlatformSettingsService _platformSettings;
public CatalogItemsController(
IUnitOfWork unitOfWork,
@@ -43,7 +49,10 @@ namespace PowderCoating.Web.Controllers
UserManager<ApplicationUser> userManager,
ITenantContext tenantContext,
IMeasurementConversionService measurementService,
ISubscriptionService subscriptionService)
ISubscriptionService subscriptionService,
ICatalogImageService catalogImageService,
IAiCatalogPriceCheckService priceCheckService,
IPlatformSettingsService platformSettings)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -53,6 +62,9 @@ namespace PowderCoating.Web.Controllers
_tenantContext = tenantContext;
_measurementService = measurementService;
_subscriptionService = subscriptionService;
_catalogImageService = catalogImageService;
_priceCheckService = priceCheckService;
_platformSettings = platformSettings;
}
/// <summary>
@@ -215,7 +227,7 @@ namespace PowderCoating.Web.Controllers
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateCatalogItemDto dto)
public async Task<IActionResult> Create(CreateCatalogItemDto dto, IFormFile? image)
{
try
{
@@ -241,6 +253,22 @@ namespace PowderCoating.Web.Controllers
await _unitOfWork.CatalogItems.AddAsync(item);
await _unitOfWork.CompleteAsync();
// Upload image after save so we have a stable item ID for the blob path.
if (image != null && image.Length > 0)
{
var imgResult = await _catalogImageService.UploadAsync(image, item.Id, companyId, null, null);
if (imgResult.Success)
{
item.ImagePath = imgResult.ImagePath;
item.ThumbnailPath = imgResult.ThumbnailPath;
await _unitOfWork.CompleteAsync();
}
else
{
TempData["Warning"] = $"Item saved but image upload failed: {imgResult.ErrorMessage}";
}
}
TempData["Success"] = $"Catalog item '{item.Name}' created successfully.";
return RedirectToAction(nameof(Index));
}
@@ -257,7 +285,6 @@ namespace PowderCoating.Web.Controllers
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -288,6 +315,10 @@ namespace PowderCoating.Web.Controllers
_logger.LogDebug("Mapping item {ItemId} to DTO", id);
var dto = _mapper.Map<UpdateCatalogItemDto>(item);
ViewBag.CurrentImagePath = item.ImagePath;
ViewBag.CurrentThumbnailPath = item.ThumbnailPath;
ViewBag.HasImage = !string.IsNullOrEmpty(item.ImagePath);
_logger.LogDebug("Populating category dropdown for item {ItemId}", id);
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
@@ -317,7 +348,7 @@ namespace PowderCoating.Web.Controllers
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateCatalogItemDto dto)
public async Task<IActionResult> Edit(int id, UpdateCatalogItemDto dto, IFormFile? image, bool removeImage = false)
{
if (id != dto.Id)
{
@@ -337,6 +368,29 @@ namespace PowderCoating.Web.Controllers
}
_mapper.Map(dto, item);
if (image != null && image.Length > 0)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var imgResult = await _catalogImageService.UploadAsync(
image, item.Id, companyId, item.ImagePath, item.ThumbnailPath);
if (imgResult.Success)
{
item.ImagePath = imgResult.ImagePath;
item.ThumbnailPath = imgResult.ThumbnailPath;
}
else
{
TempData["Warning"] = $"Item saved but image upload failed: {imgResult.ErrorMessage}";
}
}
else if (removeImage)
{
await _catalogImageService.DeleteAsync(item.ImagePath, item.ThumbnailPath);
item.ImagePath = null;
item.ThumbnailPath = null;
}
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Catalog item '{item.Name}' updated successfully.";
@@ -346,7 +400,6 @@ namespace PowderCoating.Web.Controllers
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -359,7 +412,6 @@ namespace PowderCoating.Web.Controllers
await PopulateCategoryDropdown();
await PopulateAccountDropdowns();
// Set measurement unit labels for view repopulation
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -452,7 +504,8 @@ namespace PowderCoating.Web.Controllers
i.DefaultRequiresSandblasting,
i.DefaultRequiresMasking,
i.DefaultEstimatedMinutes,
i.ApproximateArea
i.ApproximateArea,
thumbnailPath = i.ThumbnailPath
})
.ToList();
@@ -541,7 +594,8 @@ namespace PowderCoating.Web.Controllers
i.DefaultRequiresSandblasting,
i.DefaultRequiresMasking,
i.DefaultEstimatedMinutes,
i.ApproximateArea
i.ApproximateArea,
thumbnailPath = i.ThumbnailPath
})
.ToList();
@@ -581,7 +635,8 @@ namespace PowderCoating.Web.Controllers
requiresMasking = item.DefaultRequiresMasking,
estimatedMinutes = item.DefaultEstimatedMinutes ?? 0,
approximateArea = item.ApproximateArea ?? 0,
categoryName = item.Category.Name
categoryName = item.Category.Name,
thumbnailPath = item.ThumbnailPath
};
return Json(itemData);
@@ -777,6 +832,39 @@ namespace PowderCoating.Web.Controllers
return result;
}
/// <summary>
/// Serves a catalog item image (full-size or thumbnail) from Azure Blob Storage.
/// Uses plain [Authorize] (not the class-level CanManageProducts policy) so that any
/// authenticated user — including those who can only create quotes or jobs — can load
/// thumbnails rendered in the item wizard.
/// </summary>
[Authorize]
[HttpGet]
public async Task<IActionResult> Image(int id, bool thumbnail = false)
{
try
{
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id);
if (item == null)
return NotFound();
var blobPath = thumbnail ? item.ThumbnailPath : item.ImagePath;
if (string.IsNullOrEmpty(blobPath))
return NotFound();
var (success, content, contentType, error) = await _catalogImageService.DownloadAsync(blobPath);
if (!success)
return NotFound();
return File(content, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error serving catalog image for item {ItemId}", id);
return NotFound();
}
}
/// <summary>
/// Generates and streams a PDF of all active catalog items, grouped by category, including the
/// company's logo and branding. Only active items are included so the PDF serves as a
@@ -836,6 +924,228 @@ namespace PowderCoating.Web.Controllers
return RedirectToAction(nameof(Index));
}
}
// ── AI Price Check ────────────────────────────────────────────────────
/// <summary>
/// Displays the most recent AI price-check report for this company, or an empty state
/// if the check has never been run. The report is stored as JSON in the database so
/// users can review it later without re-running the AI call.
/// </summary>
public async Task<IActionResult> AiPriceCheck()
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
// Three-layer gate: platform setting → plan (Enterprise) → per-company toggle
var platformEnabled = await _platformSettings.GetBoolAsync(PlatformSettingKeys.AiCatalogPriceCheckEnabled, true);
var companyEnabled = platformEnabled && await _subscriptionService.CanUseAiCatalogPriceCheckAsync(currentUser.CompanyId);
ViewBag.AiPriceCheckEnabled = companyEnabled;
var existing = await _unitOfWork.CatalogPriceCheckReports.FindAsync(
r => r.CompanyId == currentUser.CompanyId);
var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault();
var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.IsActive && ci.DefaultPrice > 0);
ViewBag.ActiveItemCount = pricedItems.Count();
if (report != null)
{
var nextRun = report.RunAt.AddDays(90);
if (nextRun > DateTime.UtcNow)
ViewBag.NextRunAvailable = nextRun.ToLocalTime().ToString("MMMM d, yyyy");
}
CatalogPriceCheckReportDto? dto = null;
if (report != null)
{
List<CatalogItemPriceVerdict> results;
try
{
results = JsonSerializer.Deserialize<List<CatalogItemPriceVerdict>>(
report.ResultsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize stored price check report {ReportId}", report.Id);
TempData["Warning"] = "The previous report could not be loaded. Please re-run the price check.";
results = new();
}
dto = new CatalogPriceCheckReportDto
{
Id = report.Id,
RunAt = report.RunAt,
ItemsChecked = report.ItemsChecked,
Results = results,
OperatingCostsSummary = report.OperatingCostsSummary,
BelowCostCount = results.Count(r => r.Verdict == "below-cost"),
LowMarginCount = results.Count(r => r.Verdict == "low"),
HighPriceCount = results.Count(r => r.Verdict == "high"),
OkCount = results.Count(r => r.Verdict == "ok")
};
}
return View(dto);
}
/// <summary>
/// Runs the AI price check against all active catalog items using the company's current
/// operating costs. Batches items in groups of 25 to stay within model context limits.
/// Overwrites any existing report for this company rather than accumulating a history,
/// since operating costs change and old reports become misleading.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RunAiPriceCheck()
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
// Three-layer gate: platform setting → plan → per-company toggle
var platformEnabled = await _platformSettings.GetBoolAsync(PlatformSettingKeys.AiCatalogPriceCheckEnabled, true);
if (!platformEnabled || !await _subscriptionService.CanUseAiCatalogPriceCheckAsync(currentUser.CompanyId))
{
TempData["Error"] = "AI Catalog Price Check is not available on your current plan.";
return RedirectToAction(nameof(AiPriceCheck));
}
// Enforce quarterly run limit — check the most recent report for this company.
var lastReport = (await _unitOfWork.CatalogPriceCheckReports.FindAsync(
r => r.CompanyId == currentUser.CompanyId))
.OrderByDescending(r => r.RunAt).FirstOrDefault();
if (lastReport != null)
{
var nextRun = lastReport.RunAt.AddDays(90);
if (nextRun > DateTime.UtcNow)
{
TempData["Warning"] = $"Price check can only be run once per quarter. Next run available: {nextRun.ToLocalTime():MMMM d, yyyy}.";
return RedirectToAction(nameof(AiPriceCheck));
}
}
try
{
// Load active catalog items with a real price — skip $0 items (placeholders,
// category headers, etc.) since there's no pricing to evaluate.
var items = (await _unitOfWork.CatalogItems.FindAsync(
ci => ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList();
if (items.Count == 0)
{
TempData["Warning"] = "No priced catalog items to analyze. Add prices to your catalog items first.";
return RedirectToAction(nameof(AiPriceCheck));
}
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
// The full path gives Claude the coating-type context it needs — an item in
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync())
.ToDictionary(c => c.Id);
// Load company operating costs
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(
c => c.CompanyId == currentUser.CompanyId)).FirstOrDefault();
var costSummary = BuildCostSummary(costs);
// Map to service DTOs
var itemDtos = items.Select(i => new CatalogItemForPriceCheck
{
Id = i.Id,
Name = i.Name,
Description = i.Description,
CategoryName = BuildCategoryPath(i.CategoryId, allCategories),
CurrentPrice = i.DefaultPrice,
ApproximateAreaSqFt = i.ApproximateArea,
EstimatedMinutes = i.DefaultEstimatedMinutes,
RequiresSandblasting = i.DefaultRequiresSandblasting,
RequiresMasking = i.DefaultRequiresMasking
}).ToList();
// Run AI analysis
var verdicts = await _priceCheckService.AnalyzeAsync(itemDtos, costSummary);
// Soft-delete any previous report for this company
var existing = await _unitOfWork.CatalogPriceCheckReports.FindAsync(
r => r.CompanyId == currentUser.CompanyId);
foreach (var old in existing)
await _unitOfWork.CatalogPriceCheckReports.SoftDeleteAsync(old.Id);
// Save new report
var report = new CatalogPriceCheckReport
{
CompanyId = currentUser.CompanyId,
RunAt = DateTime.UtcNow,
ItemsChecked = items.Count,
ResultsJson = JsonSerializer.Serialize(verdicts),
OperatingCostsSummary = BuildCostSummaryText(costSummary)
};
await _unitOfWork.CatalogPriceCheckReports.AddAsync(report);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"AI price check complete — {items.Count} items analyzed.";
}
catch (OperationCanceledException)
{
TempData["Error"] = "The AI analysis timed out. Try again or reduce your catalog size.";
}
catch (Exception ex)
{
_logger.LogError(ex, "AI catalog price check failed");
TempData["Error"] = "An error occurred during the AI price check. Please try again.";
}
return RedirectToAction(nameof(AiPriceCheck));
}
private static ShopOperatingCostSummary BuildCostSummary(CompanyOperatingCosts? costs)
{
if (costs == null)
return new ShopOperatingCostSummary();
return new ShopOperatingCostSummary
{
LaborRatePerHour = costs.StandardLaborRate,
OvenCostPerHour = costs.OvenOperatingCostPerHour,
SandblasterCostPerHour = costs.SandblasterCostPerHour,
CoatingBoothCostPerHour = costs.CoatingBoothCostPerHour,
PowderCostPerSqFt = costs.PowderCoatingCostPerSqFt,
ShopSuppliesRatePercent = costs.ShopSuppliesRate,
MarkupOrMarginPercent = costs.PricingMode == PricingMode.MarginOnTotalCost
? costs.TargetMarginPercent
: costs.GeneralMarkupPercentage,
PricingMode = costs.PricingMode == PricingMode.MarginOnTotalCost ? "margin" : "markup",
ShopMinimumCharge = costs.ShopMinimumCharge,
AiContextProfile = costs.AiContextProfile
};
}
private static string BuildCostSummaryText(ShopOperatingCostSummary c) =>
$"Labor ${c.LaborRatePerHour:F2}/hr | Oven ${c.OvenCostPerHour:F2}/hr | " +
$"Blaster ${c.SandblasterCostPerHour:F2}/hr | Booth ${c.CoatingBoothCostPerHour:F2}/hr | " +
$"Powder ${c.PowderCostPerSqFt:F2}/sqft | " +
$"{(c.PricingMode == "margin" ? "Margin" : "Markup")} {c.MarkupOrMarginPercent:F1}%";
/// <summary>
/// Walks up the category parent chain to produce a full path like "Cerakote > Firearms",
/// giving Claude the coating-type context it needs for accurate pricing analysis.
/// </summary>
private static string BuildCategoryPath(int? categoryId, Dictionary<int, CatalogCategory> all)
{
if (categoryId == null) return "Uncategorized";
var parts = new List<string>();
var current = all.GetValueOrDefault(categoryId.Value);
while (current != null)
{
parts.Insert(0, current.Name);
current = current.ParentCategoryId.HasValue
? all.GetValueOrDefault(current.ParentCategoryId.Value)
: null;
}
return parts.Count > 0 ? string.Join(" > ", parts) : "Uncategorized";
}
}
// Helper class for hierarchical display
@@ -573,53 +573,40 @@ public class JobsController : Controller
/// <summary>
/// Shows the status-bump selection page for a shop-floor QR code scan.
/// This endpoint is AllowAnonymous — it is accessed by workers scanning a printed QR code
/// on a work order, not by logged-in users. The <paramref name="token"/> is the job's
/// ShopAccessCode GUID. IgnoreQueryFilters is used so the scan works even if the worker's
/// device has no active session (common on shared shop tablets).
/// Requires authentication — workers must be logged in before scanning. Tenant isolation
/// is enforced by the normal global query filter on <c>GetByIdAsync</c>.
/// </summary>
[AllowAnonymous]
public async Task<IActionResult> StatusBump(Guid token)
public async Task<IActionResult> StatusBump(int id)
{
// Find job by ShopAccessCode — ignore tenant/soft-delete filters so the
// anonymous scan always finds the job regardless of active user session.
var jobs = await _unitOfWork.Jobs.FindAsync(
j => j.ShopAccessCode == token, true,
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false,
j => j.JobStatus,
j => j.JobPriority,
j => j.Customer);
var job = jobs.FirstOrDefault();
if (job == null) return NotFound("Work order token not found.");
if (job == null) return NotFound();
// Load all status lookups to determine next step
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
.OrderBy(s => s.DisplayOrder).ToList();
ViewBag.AllStatuses = allStatuses;
ViewBag.Job = job;
ViewBag.Token = token;
ViewBag.JobId = id;
return View();
}
/// <summary>
/// Processes a QR-code status bump from the shop floor — also AllowAnonymous.
/// Validates the token, applies the new status, records the change history, and broadcasts
/// a SignalR update so the office dashboard refreshes in real time.
/// The "bumped by" user is recorded as the ShopAccessCode token string (anonymous actor).
/// Processes a QR-code status bump from the shop floor. Requires authentication.
/// Records the authenticated user's name in status history.
/// </summary>
[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StatusBump(Guid token, int newStatusId)
public async Task<IActionResult> StatusBump(int id, int newStatusId)
{
var jobs = await _unitOfWork.Jobs.FindAsync(
j => j.ShopAccessCode == token, true,
var job = await _unitOfWork.Jobs.GetByIdAsync(id, false,
j => j.JobStatus,
j => j.Customer);
var job = jobs.FirstOrDefault();
if (job == null) return NotFound("Work order token not found.");
if (job == null) return NotFound();
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList();
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
@@ -630,28 +617,29 @@ public class JobsController : Controller
job.UpdatedAt = DateTime.UtcNow;
if (newStatus.StatusCode == "COMPLETED") job.CompletedDate = DateTime.UtcNow;
var userName = User.Identity?.Name ?? "Shop Floor";
await _unitOfWork.JobStatusHistory.AddAsync(new JobStatusHistory
{
JobId = job.Id,
FromStatusId = oldStatusId,
ToStatusId = newStatusId,
ChangedDate = DateTime.UtcNow,
Notes = "Updated via shop floor QR scan",
Notes = $"Updated via shop floor QR scan by {userName}",
CompanyId = job.CompanyId,
CreatedAt = DateTime.UtcNow
});
await _unitOfWork.CompleteAsync();
// Reload job status for redirect display
return RedirectToAction(nameof(StatusBump), new { token });
return RedirectToAction(nameof(StatusBump), new { id });
}
/// <summary>
/// Renders the printable work order view for a job.
/// Loads all job items with their coats and prep services so the printed sheet contains
/// the full powder specification, colors, and preparation instructions for shop workers.
/// The work order also includes the QR code for ShopAccessCode-based status bumps.
/// Generates two sets of QR codes: a top "view" code linking to the authenticated job
/// details page, and bottom action codes for status bumping and powder usage logging.
/// </summary>
public async Task<IActionResult> WorkOrder(int? id)
{
@@ -713,13 +701,19 @@ public class JobsController : Controller
ViewBag.Company = company;
}
// Generate QR code for shop floor status bumping
var statusBumpUrl = Url.Action("StatusBump", "Jobs", new { token = job.ShopAccessCode }, Request.Scheme)!;
using var qrGenerator = new QRCoder.QRCodeGenerator();
// Top QR: view/verify the job on mobile (authenticated job details page)
var detailsUrl = Url.Action("Details", "Jobs", new { id = job.Id }, Request.Scheme)!;
using var viewQrData = qrGenerator.CreateQrCode(detailsUrl, QRCoder.QRCodeGenerator.ECCLevel.M);
using var viewQrCode = new QRCoder.PngByteQRCode(viewQrData);
ViewBag.ViewQrCodeBase64 = Convert.ToBase64String(viewQrCode.GetGraphic(4));
// Bottom QR: status bump (authenticated, job ID routed)
var statusBumpUrl = Url.Action("StatusBump", "Jobs", new { id = job.Id }, Request.Scheme)!;
using var qrData = qrGenerator.CreateQrCode(statusBumpUrl, QRCoder.QRCodeGenerator.ECCLevel.M);
using var qrCode = new QRCoder.PngByteQRCode(qrData);
var qrBytes = qrCode.GetGraphic(4);
ViewBag.QrCodeBase64 = Convert.ToBase64String(qrBytes);
ViewBag.QrCodeBase64 = Convert.ToBase64String(qrCode.GetGraphic(4));
ViewBag.StatusBumpUrl = statusBumpUrl;
// Generate QR codes for each unique inventory powder item so workers can
@@ -3242,7 +3236,8 @@ public class JobsController : Controller
categoryName = i.Category.Name,
price = i.DefaultPrice,
approxArea = i.ApproximateArea ?? 0m,
defaultMinutes = i.DefaultEstimatedMinutes ?? 0
defaultMinutes = i.DefaultEstimatedMinutes ?? 0,
thumbnailPath = i.ThumbnailPath
}).ToList();
// Merchandise items (IsMerchandise = true) — for the sales wizard step
@@ -0,0 +1,336 @@
using System.Text;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Handles WebAuthn / FIDO2 passkey registration and authentication.
/// Registration requires an authenticated session (user logs in once with password,
/// then enrolls a passkey for future logins). Authentication is anonymous — the
/// browser sends the credential before any session exists.
///
/// Fido2 is constructed per-request from the incoming Host header so the RPID
/// matches automatically on localhost, dev, staging, and production without any
/// environment-specific configuration.
/// </summary>
[Route("[controller]/[action]")]
public class PasskeyController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ApplicationDbContext _db;
private readonly ILogger<PasskeyController> _logger;
private const string RegChallengeKey = "passkey:reg:challenge";
private const string AuthChallengeKey = "passkey:auth:challenge";
public PasskeyController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ApplicationDbContext db,
ILogger<PasskeyController> logger)
{
_userManager = userManager;
_signInManager = signInManager;
_db = db;
_logger = logger;
}
/// <summary>
/// Builds a Fido2 instance whose RPID and origin are derived from the current
/// request, so the same code works on localhost, dev, and production unchanged.
/// </summary>
private IFido2 BuildFido2()
{
var req = HttpContext.Request;
var host = req.Host.Host; // "localhost" or "myapp.azurewebsites.net"
var port = req.Host.Port;
var origin = port.HasValue
? $"{req.Scheme}://{host}:{port}"
: $"{req.Scheme}://{host}";
var config = new Fido2Configuration
{
ServerDomain = host,
ServerName = "Powder Coating Logix",
Origins = new HashSet<string> { origin },
TimestampDriftTolerance = 300
};
return new Fido2(config);
}
// ─── Registration ────────────────────────────────────────────────────────
/// <summary>
/// Returns a WebAuthn creation options object for the currently signed-in user.
/// Stores the challenge in session so Register can verify it.
/// </summary>
[HttpPost]
[Authorize]
public async Task<IActionResult> RegisterOptions()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var existingKeys = await _db.UserPasskeys
.Where(p => p.UserId == user.Id)
.Select(p => p.CredentialId)
.ToListAsync();
var fidoUser = new Fido2User
{
Id = Encoding.UTF8.GetBytes(user.Id),
Name = user.Email!,
DisplayName = user.FullName ?? user.Email!
};
var excludeCredentials = existingKeys
.Select(k => new PublicKeyCredentialDescriptor(k))
.ToList();
var authenticatorSelection = new AuthenticatorSelection
{
ResidentKey = ResidentKeyRequirement.Required,
UserVerification = UserVerificationRequirement.Required
};
var options = BuildFido2().RequestNewCredential(new RequestNewCredentialParams
{
User = fidoUser,
ExcludeCredentials = excludeCredentials,
AuthenticatorSelection = authenticatorSelection,
AttestationPreference = AttestationConveyancePreference.None
});
HttpContext.Session.SetString(RegChallengeKey, options.ToJson());
return Ok(options);
}
/// <summary>
/// Verifies the authenticator response and persists the new passkey credential.
/// </summary>
[HttpPost]
[Authorize]
public async Task<IActionResult> Register(
[FromBody] AuthenticatorAttestationRawResponse attestationResponse,
[FromQuery] string? deviceName)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var optionsJson = HttpContext.Session.GetString(RegChallengeKey);
if (string.IsNullOrEmpty(optionsJson))
return BadRequest(new { error = "Session expired — please try again." });
HttpContext.Session.Remove(RegChallengeKey);
RegisteredPublicKeyCredential credential;
try
{
var options = CredentialCreateOptions.FromJson(optionsJson);
credential = await BuildFido2().MakeNewCredentialAsync(new MakeNewCredentialParams
{
AttestationResponse = attestationResponse,
OriginalOptions = options,
IsCredentialIdUniqueToUserCallback = async (args, _) =>
!await _db.UserPasskeys.AnyAsync(p => p.CredentialId == args.CredentialId)
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Passkey registration failed for user {UserId}", user.Id);
return BadRequest(new { error = ex.Message });
}
var passkey = new UserPasskey
{
UserId = user.Id,
CompanyId = user.CompanyId,
CredentialId = credential.Id,
PublicKey = credential.PublicKey,
UserHandle = credential.User.Id,
SignCount = credential.SignCount,
DeviceFriendlyName = string.IsNullOrWhiteSpace(deviceName) ? null : deviceName.Trim()
};
_db.UserPasskeys.Add(passkey);
await _db.SaveChangesAsync();
_logger.LogInformation("Passkey registered for user {UserId} ({DeviceName})",
user.Id, passkey.DeviceFriendlyName ?? "(unnamed)");
return Ok(new { message = "Passkey registered successfully." });
}
// ─── Authentication ───────────────────────────────────────────────────────
/// <summary>
/// Returns a WebAuthn assertion options object. No session required — called before login.
/// </summary>
[HttpPost]
[AllowAnonymous]
public IActionResult LoginOptions()
{
var options = BuildFido2().GetAssertionOptions(new GetAssertionOptionsParams
{
AllowedCredentials = [],
UserVerification = UserVerificationRequirement.Required
});
HttpContext.Session.SetString(AuthChallengeKey, options.ToJson());
return Ok(options);
}
/// <summary>
/// Verifies the assertion response against stored credentials and signs the user in.
/// </summary>
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] AuthenticatorAssertionRawResponse assertionResponse)
{
var optionsJson = HttpContext.Session.GetString(AuthChallengeKey);
if (string.IsNullOrEmpty(optionsJson))
return BadRequest(new { error = "Session expired — please try again." });
HttpContext.Session.Remove(AuthChallengeKey);
// Look up passkey by credential ID (RawId is byte[], Id is base64url string)
var credentialId = assertionResponse.RawId;
var passkey = await _db.UserPasskeys
.FirstOrDefaultAsync(p => p.CredentialId == credentialId);
if (passkey == null)
return BadRequest(new { error = "Passkey not recognised." });
// Load the user — verify account is still active
var user = await _userManager.FindByIdAsync(passkey.UserId);
if (user == null || !user.IsActive)
return BadRequest(new { error = "Account not found or deactivated." });
if (await _userManager.IsLockedOutAsync(user))
return BadRequest(new { error = "Account is locked. Please contact your administrator." });
VerifyAssertionResult verifyResult;
try
{
var options = AssertionOptions.FromJson(optionsJson);
verifyResult = await BuildFido2().MakeAssertionAsync(new MakeAssertionParams
{
AssertionResponse = assertionResponse,
OriginalOptions = options,
StoredPublicKey = passkey.PublicKey,
StoredSignatureCounter = (uint)passkey.SignCount,
IsUserHandleOwnerOfCredentialIdCallback = (args, _) =>
Task.FromResult(args.UserHandle.SequenceEqual(passkey.UserHandle))
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Passkey assertion failed for user {UserId}", passkey.UserId);
return BadRequest(new { error = "Passkey verification failed." });
}
// Update sign count and last-used timestamp
passkey.SignCount = verifyResult.SignCount;
passkey.LastUsedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
// Sign in — passkey satisfies both factors; no further 2FA required
await _signInManager.SignInAsync(user, isPersistent: false);
_logger.LogInformation("User {UserId} signed in via passkey", user.Id);
return Ok(new { redirectUrl = Url.Action("Index", "Dashboard") });
}
// ─── Management ───────────────────────────────────────────────────────────
// ─── Post-login enrollment prompt ─────────────────────────────────────────
/// <summary>
/// Shown immediately after password login. Skips to returnUrl if the user already
/// has a passkey or has previously dismissed the prompt.
/// </summary>
[Authorize]
[HttpGet("/Passkey/EnrollPrompt")]
public async Task<IActionResult> EnrollPrompt(string? returnUrl)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var hasPasskey = await _db.UserPasskeys.AnyAsync(p => p.UserId == user.Id);
if (hasPasskey || user.PasskeyPromptDismissed)
return Redirect(returnUrl ?? "/");
ViewBag.ReturnUrl = returnUrl ?? "/";
return View();
}
/// <summary>
/// Permanently dismisses the passkey enrollment prompt for this user. They can
/// re-enable it from Profile → Security at any time.
/// </summary>
[Authorize]
[HttpPost("/Passkey/DismissPrompt")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DismissPrompt(string? returnUrl)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
user.PasskeyPromptDismissed = true;
await _userManager.UpdateAsync(user);
return Redirect(returnUrl ?? "/");
}
/// <summary>Shows all passkeys registered by the current user.</summary>
[Authorize]
[HttpGet("/Passkey/Manage")]
public async Task<IActionResult> Manage()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var passkeys = await _db.UserPasskeys
.Where(p => p.UserId == user.Id)
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
return View(passkeys);
}
/// <summary>Removes a specific passkey for the current user.</summary>
[HttpPost]
[Authorize]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Remove(int id)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var passkey = await _db.UserPasskeys
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id);
if (passkey == null)
return NotFound();
_db.UserPasskeys.Remove(passkey);
await _db.SaveChangesAsync();
_logger.LogInformation("Passkey {PasskeyId} removed for user {UserId}", id, user.Id);
TempData["Success"] = "Passkey removed.";
return RedirectToAction(nameof(Manage));
}
}
@@ -63,6 +63,7 @@ public class PlatformSubscriptionController : Controller
AllowAccounting = c.AllowAccounting,
AllowAiPhotoQuotes = c.AllowAiPhotoQuotes,
AllowAiInventoryAssist = c.AllowAiInventoryAssist,
AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck,
IsActive = c.IsActive,
SortOrder = c.SortOrder
}).ToList();
@@ -102,6 +103,7 @@ public class PlatformSubscriptionController : Controller
AllowAccounting = config.AllowAccounting,
AllowAiPhotoQuotes = config.AllowAiPhotoQuotes,
AllowAiInventoryAssist = config.AllowAiInventoryAssist,
AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck,
IsActive = config.IsActive
};
@@ -146,6 +148,7 @@ public class PlatformSubscriptionController : Controller
config.AllowAccounting = dto.AllowAccounting;
config.AllowAiPhotoQuotes = dto.AllowAiPhotoQuotes;
config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist;
config.AllowAiCatalogPriceCheck = dto.AllowAiCatalogPriceCheck;
config.IsActive = dto.IsActive;
await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config);
@@ -2686,7 +2686,8 @@ public class QuotesController : Controller
categoryName = i.Category.Name,
price = i.DefaultPrice,
approxArea = i.ApproximateArea ?? 0m,
defaultMinutes = i.DefaultEstimatedMinutes ?? 0
defaultMinutes = i.DefaultEstimatedMinutes ?? 0,
thumbnailPath = i.ThumbnailPath
}).ToList();
// Merchandise items (IsMerchandise = true) — for the sales wizard step
@@ -462,12 +462,14 @@ public class SubscriptionManagementController : Controller
/// <param name="id">Primary key of the company to update.</param>
/// <param name="aiPhotoQuotesEnabled">Whether AI photo quoting is enabled for this company.</param>
/// <param name="aiInventoryAssistEnabled">Whether AI inventory assistance is enabled for this company.</param>
/// <param name="aiCatalogPriceCheckEnabled">Whether AI catalog price check is enabled for this company.</param>
/// <param name="maxAiPhotoQuotesPerMonthOverride">Monthly AI photo quote limit override; 0 = plan default.</param>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateFeatureFlags(
int id,
bool aiPhotoQuotesEnabled,
bool aiInventoryAssistEnabled,
bool aiCatalogPriceCheckEnabled,
int? maxAiPhotoQuotesPerMonthOverride)
{
var company = await _db.Companies.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id);
@@ -475,6 +477,7 @@ public class SubscriptionManagementController : Controller
company.AiPhotoQuotesEnabled = aiPhotoQuotesEnabled;
company.AiInventoryAssistEnabled = aiInventoryAssistEnabled;
company.AiCatalogPriceCheckEnabled = aiCatalogPriceCheckEnabled;
company.MaxAiPhotoQuotesPerMonthOverride = NullIfZero(maxAiPhotoQuotesPerMonthOverride);
company.UpdatedAt = DateTime.UtcNow;
company.UpdatedBy = User.Identity?.Name;
@@ -296,6 +296,16 @@ public static class HelpKnowledgeBase
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice."
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
*Top QR View Job:* Located in the header next to the job number. Scanning it opens the full Job Details page on the worker's phone shows all items, catalog images, powder specs, coatings, prep services, and special instructions. Use this to verify you're working the right job and to see catalog product images on mobile.
*Bottom QR codes Actions:*
- **Update Status** advances the job to its next status stage. Opens a dedicated mobile-friendly status bump page where the worker confirms the new stage. The status change is recorded in history with the logged-in worker's name.
- **Log Powder Usage** one QR per unique powder/inventory item on the job. Scanning opens the inventory usage log page pre-filled with that item and the job, so the worker can record actual lbs used without navigating through the app.
All QR codes require login workers must have an active account. Logging in once on their phone is sufficient for the session.
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
- Access: Jobs list page printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank.
- The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line.
@@ -916,9 +926,12 @@ public static class HelpKnowledgeBase
**How to add a catalog item:**
1. Go to [Catalog Items](/CatalogItems) "New Item"
2. Enter name, category, and the all-in price (including your labor and margin nothing will be added on top)
3. Save
3. Optionally upload an image in the Item Image section (jpg/jpeg/png/gif/webp, max 10 MB a 200×200 thumbnail is generated automatically)
4. Save
Catalog items can be selected in the quote/job wizard as an alternative to the full calculated or custom item workflow.
**Item images:** Each catalog item supports one optional image. Upload or replace it on the item's Edit page. When no image is set, a gray placeholder icon appears instead. Images appear as thumbnails in the catalog list and in the quote/job item wizard. Hovering over a thumbnail in the wizard shows a larger preview near the cursor so staff can quickly confirm the right part.
Catalog items can be selected in the quote/job wizard as an alternative to the full calculated or custom item workflow. The wizard's product list includes a search/filter box and shows thumbnails next to each item name for visual identification.
**Saving to catalog directly from the item wizard (Save-to-Catalog step):**
When a user completes a Calculated or AI Photo Quote item in the wizard, a final optional step appears: "Save to Product Catalog." This lets them create a reusable catalog entry from the item they just configured without navigating to the Catalog Items page separately.
@@ -946,6 +959,42 @@ public static class HelpKnowledgeBase
---
## AI CATALOG PRICE CHECK
**Where:** [Catalog Items](/CatalogItems) "AI Price Check" button (top-right of the catalog list)
**What it does:** Reviews every active, priced catalog item against your shop's actual operating costs. For each item the AI estimates a realistic surface area and processing time, calculates a cost floor (labor + equipment + materials), then compares that to the item's current price and returns a verdict. Results are saved so you can review them any time without re-running the analysis.
**Verdicts:**
- **Below Cost** price is at or below the cost floor; the shop loses money on this item
- **Thin Margin** price is above cost but below the shop's target margin
- **High** price appears significantly above typical market rates
- **OK** price is within a reasonable range
**Confidence levels:** Each result shows a confidence level (High / Medium / Low) based on how specific the item name is. Vague items like "Custom Part" or "Special Job" will be flagged as Low confidence treat those results with extra skepticism.
**Category paths:** The AI uses the full category path (e.g. "Cerakote > Firearms") to determine the coating process. Cerakote and other specialty coatings have very different cost profiles than standard powder coat make sure items are in the correct category for the most accurate results.
**Run limit:** Analysis can be run once per quarter (every 90 days). The button shows the next available date when a recent run exists. This limit applies per company.
**Before running:** Make sure your operating costs (labor rate, oven cost, powder cost, etc.) are up to date in [Company Settings](/CompanySettings). Stale costs will produce inaccurate verdicts.
**How to use the results:**
1. Go to Catalog Items click "AI Price Check"
2. Click "Analyze Catalog with AI" a progress overlay shows estimated completion time (allow 710 minutes for large catalogs)
3. Review the results sorted by severity (Below Cost first, then Thin Margin, High, OK)
4. For flagged items, click "Edit Price" to update the price directly from the results page
5. Items marked Low confidence should be verified manually the AI had limited information to work with
**Common questions:**
- "How accurate is the AI analysis?" Results are estimates based on industry knowledge and your operating costs. Always apply your own judgment before changing prices.
- "Why is an item showing $0.00 cost floor?" The item likely had an error during analysis. Re-run the check or verify the item has a valid name and category.
- "Can I run it more often?" The quarterly limit is enforced to manage costs. Contact your administrator if you need an exception.
- "Items in my Cerakote category are priced wrong" Make sure those items are in a category whose full path includes 'Cerakote' (e.g. "Cerakote > Firearms"). The AI uses the category path to determine the coating type.
- "The progress bar finished but the page didn't load" The analysis is still running server-side. Wait for the page to redirect automatically do not close the tab.
---
## USER PROFILE
**Where:** [My Profile](/Profile) via the user menu (top-right)
@@ -956,9 +1005,21 @@ public static class HelpKnowledgeBase
- Upload a profile photo
- Choose display theme (light/dark)
- Manage two-factor authentication settings
- Register passkeys for biometric login (Face ID, fingerprint, Windows Hello)
**Two-Factor Authentication:** [/TwoFactorSetup](/TwoFactorSetup) Set up or manage 2FA for your account.
**Passkeys & Biometric Login:** [/Passkey/Manage](/Passkey/Manage) Register your phone's Face ID, fingerprint, or Windows Hello so future logins don't require typing a password.
### How passkeys work
- Log in once with your password as normal.
- After login, a prompt appears in the bottom-right corner offering to enable biometric login on that device.
- Click Enable and follow the device prompt (Face ID on iPhone, fingerprint/face on Android, Windows Hello on PC).
- Next time you log in, tap "Use Face ID / Biometric" (or the platform equivalent) on the login page no password needed.
- Each device you enroll appears on the Passkeys & Biometrics page. You can remove individual devices at any time.
- The server always checks that your account is active and not locked before allowing passkey login a disabled account cannot bypass this with a passkey.
- Passkeys require HTTPS and a compatible browser (Safari 16+, Chrome 108+, Edge 108+, or any modern Android browser).
---
## BILLING & SUBSCRIPTION
@@ -18,6 +18,8 @@
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
<PackageReference Include="EPPlus" Version="7.0.0" />
<PackageReference Include="Fido2" Version="4.0.1" />
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
<PackageReference Include="Markdig" Version="0.40.0" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.11" />
+6
View File
@@ -193,12 +193,14 @@ builder.Services.AddSingleton<IAzureBlobStorageService, AzureBlobStorageService>
builder.Services.AddScoped<IProfilePhotoService, ProfilePhotoService>();
builder.Services.AddScoped<IJobPhotoService, JobPhotoService>();
builder.Services.AddScoped<IQuotePhotoService, QuotePhotoService>();
builder.Services.AddScoped<ICatalogImageService, CatalogImageService>();
builder.Services.AddScoped<IAiQuoteService, AiQuoteService>();
builder.Services.AddScoped<IAiQuickQuoteService, AiQuickQuoteService>();
builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>();
builder.Services.AddScoped<IAiSchedulingService, AiSchedulingService>();
builder.Services.AddScoped<IAccountingAiService, AccountingAiService>();
builder.Services.AddScoped<IAiHelpService, AiHelpService>();
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
builder.Services.AddHttpClient();
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
@@ -289,6 +291,10 @@ builder.Services.AddSession(options =>
// Add memory cache
builder.Services.AddMemoryCache();
// Fido2/WebAuthn: no DI registration needed — PasskeyController builds a
// per-request Fido2 instance from the incoming Host header so the RPID matches
// automatically on every environment without config changes.
// Configure authorization policies for multi-tenancy
builder.Services.AddAuthorization(options =>
{
@@ -0,0 +1,337 @@
@model PowderCoating.Application.DTOs.AI.CatalogPriceCheckReportDto?
@{
ViewData["Title"] = "AI Catalog Price Check";
ViewData["PageIcon"] = "bi-robot";
ViewData["PageHelpTitle"] = "AI Catalog Price Check";
ViewData["PageHelpContent"] = "The AI Price Check reviews every item in your catalog against your actual operating costs and flags items that may be priced below cost, have thin margins, or appear unusually high. Results are estimates based on industry knowledge and your shop's rates — always apply your own judgment before changing prices.";
var sortedResults = Model?.Results
.OrderBy(r => r.Verdict switch
{
"below-cost" => 0,
"low" => 1,
"high" => 2,
_ => 3
})
.ThenBy(r => r.Name)
.ToList() ?? new List<PowderCoating.Application.DTOs.AI.CatalogItemPriceVerdict>();
}
@section Styles {
<style>
.verdict-badge { font-size: 0.8rem; font-weight: 600; padding: 0.3em 0.7em; border-radius: 20px; }
.verdict-below-cost { background: #fee2e2; color: #991b1b; }
.verdict-low { background: #fef3c7; color: #92400e; }
.verdict-high { background: #e0e7ff; color: #3730a3; }
.verdict-ok { background: #d1fae5; color: #065f46; }
.confidence-low { opacity: 0.6; }
.price-card { border-left: 4px solid #e5e7eb; }
.price-card.below-cost { border-left-color: #ef4444; }
.price-card.low { border-left-color: #f59e0b; }
.price-card.high { border-left-color: #6366f1; }
.price-card.ok { border-left-color: #10b981; }
.cost-table td { font-size: 0.85rem; }
.summary-stat { text-align: center; }
.summary-stat .num { font-size: 2rem; font-weight: 700; line-height: 1; }
.summary-stat .lbl { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
.run-btn-wrap { min-height: 3rem; }
/* Progress overlay */
#price-check-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
z-index: 1050;
align-items: center;
justify-content: center;
}
#price-check-overlay.active { display: flex; }
.progress-card {
background: #fff;
border-radius: 1rem;
padding: 2.5rem 2rem;
width: 100%;
max-width: 440px;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
}
.progress-card .icon { font-size: 3rem; color: #4f46e5; margin-bottom: 1rem; }
.progress-card h5 { font-weight: 700; margin-bottom: 0.25rem; }
.progress-card .status-msg { font-size: 0.9rem; color: #64748b; min-height: 1.4em; margin-bottom: 1.25rem; }
.progress-bar-track {
height: 8px;
background: #e2e8f0;
border-radius: 99px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
border-radius: 99px;
width: 0%;
transition: width 0.6s ease;
}
.progress-card .pct-label { font-size: 0.8rem; color: #94a3b8; }
</style>
}
<!-- Progress overlay (shown while AI is running) -->
<div id="price-check-overlay">
<div class="progress-card">
<div class="icon"><i class="bi bi-robot"></i></div>
<h5>Analyzing your catalog</h5>
<p class="status-msg" id="overlay-status">Preparing items…</p>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="overlay-bar"></div>
</div>
<div class="pct-label"><span id="overlay-pct">0</span>% complete</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i> Back to Catalog
</a>
@if (!(bool)(ViewBag.AiPriceCheckEnabled ?? true))
{
<div class="text-end">
<button class="btn btn-primary" disabled>
<i class="bi bi-robot me-2"></i>Analyze Catalog with AI
</button>
<div class="small text-muted mt-1">Available on the Enterprise plan</div>
</div>
}
else if (ViewBag.NextRunAvailable != null)
{
<div class="text-end">
<button class="btn btn-primary" disabled>
<i class="bi bi-robot me-2"></i>Analyze Catalog with AI
</button>
<div class="small text-muted mt-1">Next run available: @ViewBag.NextRunAvailable</div>
</div>
}
else
{
<form asp-action="RunAiPriceCheck" method="post" id="runForm" class="run-btn-wrap">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-primary" id="runBtn"
data-item-count="@(ViewBag.ActiveItemCount ?? 0)">
<i class="bi bi-robot me-2"></i>Analyze Catalog with AI
</button>
</form>
}
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
</div>
}
@if (TempData["Warning"] != null)
{
<div class="alert alert-warning alert-permanent mb-4">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Warning"]
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent mb-4">
<i class="bi bi-x-circle me-2"></i>@TempData["Error"]
</div>
}
<!-- What this does -->
<div class="card mb-4 border-0 bg-light">
<div class="card-body">
<div class="d-flex gap-3">
<div class="flex-shrink-0 text-primary" style="font-size:1.75rem;"><i class="bi bi-info-circle"></i></div>
<div>
<h6 class="fw-semibold mb-1">What this analysis does</h6>
<p class="small text-muted mb-2">
Our AI system reviews every active, priced item in your catalog against your shop's actual operating costs —
labor, oven time, sandblasting, coating booth, and powder material. For each item it estimates a
realistic surface area and processing time, calculates a cost floor, then compares that to your
current price and returns one of four verdicts:
</p>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-below-cost">Below Cost</span><span class="small text-muted align-self-center">— you're losing money on this item</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-low">Thin Margin</span><span class="small text-muted align-self-center">— above cost floor but below your target margin</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-high">High</span><span class="small text-muted align-self-center">— significantly above typical market rates</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="verdict-badge verdict-ok">OK</span><span class="small text-muted align-self-center">— price is within a reasonable range</span>
</div>
<p class="small text-muted mb-0">
<i class="bi bi-exclamation-triangle me-1 text-warning"></i>
Results are estimates based on industry knowledge and your shop's rates. Always apply your own
judgment before changing prices. Make sure your
<a asp-controller="CompanySettings" asp-action="Index">operating costs</a> are up to date for the most accurate results.
Analysis can be run once per quarter.
</p>
</div>
</div>
</div>
</div>
@if (Model == null)
{
<!-- Empty state -->
<div class="card text-center py-5">
<div class="card-body">
<i class="bi bi-robot text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3">No analysis has been run yet</h4>
<p class="text-muted mb-4">
Click <strong>Analyze Catalog with AI</strong> above to get started.
</p>
</div>
</div>
}
else
{
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-danger">@Model.BelowCostCount</div>
<div class="lbl mt-1">Below Cost</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-warning">@Model.LowMarginCount</div>
<div class="lbl mt-1">Thin Margin</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-primary">@Model.HighPriceCount</div>
<div class="lbl mt-1">Possibly High</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-success">@Model.OkCount</div>
<div class="lbl mt-1">Looks Good</div>
</div>
</div>
</div>
</div>
<!-- Meta / costs used -->
<div class="card mb-4">
<div class="card-body d-flex flex-wrap align-items-center gap-3">
<i class="bi bi-clock-history text-muted"></i>
<span class="text-muted small">
Run @Model.RunAt.ToLocalTime().ToString("MMM d, yyyy h:mm tt") &bull;
@Model.ItemsChecked items checked
</span>
<span class="badge bg-light text-secondary small ms-auto">
Costs used: @Model.OperatingCostsSummary
</span>
</div>
</div>
<!-- Results list -->
<div class="row g-3">
@foreach (var item in sortedResults!)
{
var cardClass = item.Verdict switch
{
"below-cost" => "below-cost",
"low" => "low",
"high" => "high",
_ => "ok"
};
var verdictClass = item.Verdict switch
{
"below-cost" => "verdict-below-cost",
"low" => "verdict-low",
"high" => "verdict-high",
_ => "verdict-ok"
};
var verdictLabel = item.Verdict switch
{
"below-cost" => "Below Cost",
"low" => "Thin Margin",
"high" => "High",
_ => "OK"
};
<div class="col-12 col-lg-6">
<div class="card price-card @cardClass @(item.Confidence == "low" ? "confidence-low" : "")">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>@item.Name</strong>
@if (item.Confidence == "low")
{
<span class="badge bg-light text-secondary ms-2" title="Item name was too vague for a confident estimate">
<i class="bi bi-question-circle me-1"></i>Low confidence
</span>
}
</div>
<span class="verdict-badge @verdictClass">@verdictLabel</span>
</div>
<div class="row g-2 mb-2">
<div class="col-4 text-center">
<div class="small text-muted">Current</div>
<div class="fw-semibold">@item.CurrentPrice.ToString("C")</div>
</div>
<div class="col-4 text-center">
<div class="small text-muted">Cost Floor</div>
<div class="fw-semibold @(item.CostFloor > item.CurrentPrice ? "text-danger" : "")">
@item.CostFloor.ToString("C")
</div>
</div>
<div class="col-4 text-center">
<div class="small text-muted">Suggested</div>
<div class="fw-semibold text-primary">
@item.SuggestedPriceMin.ToString("C") @item.SuggestedPriceMax.ToString("C")
</div>
</div>
</div>
<div class="small text-muted mb-1">
<i class="bi bi-rulers me-1"></i>
Est. @item.EstimatedSqFtMin@item.EstimatedSqFtMax sqft &bull;
@item.EstimatedMinutesMin@item.EstimatedMinutesMax min
</div>
<p class="small mb-1">@item.Reasoning</p>
<details class="small">
<summary class="text-muted" style="cursor:pointer;">Assumptions</summary>
<p class="mt-1 mb-0 text-muted">@item.Assumptions</p>
</details>
<div class="mt-2 text-end">
<a asp-action="Edit" asp-route-id="@item.CatalogItemId"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Edit Price
</a>
</div>
</div>
</div>
</div>
}
</div>
}
@section Scripts {
<script src="~/js/catalog-price-check.js"></script>
}
@@ -14,7 +14,7 @@
</h4>
</div>
<div class="card-body">
<form asp-action="Create" method="post">
<form asp-action="Create" method="post" enctype="multipart/form-data">
<partial name="_ValidationSummary" />
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-4" role="alert">
@@ -159,6 +159,17 @@
<div class="form-text">When checked, this item appears in the merchandise picker on invoices and can be sold without a job (e.g. branded apparel, retail cleaning products).</div>
</div>
<!-- Item Image -->
<h5 class="border-bottom pb-2 mb-3 mt-4">Item Image <span class="text-muted small fw-normal">(optional)</span></h5>
<div class="mb-3">
<label for="image" class="form-label fw-semibold">Upload Image</label>
<input type="file" class="form-control" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp — max 10 MB. A 200×200 thumbnail is generated automatically.</div>
<div id="imagePreview" class="mt-2 d-none">
<img id="imagePreviewImg" src="" alt="Preview" style="max-width:200px;max-height:200px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;" />
</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between mt-4">
<a asp-action="Index" class="btn btn-outline-secondary">
@@ -349,5 +360,17 @@
}
}
});
function previewCatalogImage(input) {
const preview = document.getElementById('imagePreview');
const img = document.getElementById('imagePreviewImg');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = e => { img.src = e.target.result; preview.classList.remove('d-none'); };
reader.readAsDataURL(input.files[0]);
} else {
preview.classList.add('d-none');
}
}
</script>
}
@@ -134,8 +134,25 @@
</div>
</div>
<!-- Right Column: Actions -->
<!-- Right Column: Image + Actions -->
<div class="col-lg-4">
@if (!string.IsNullOrEmpty(Model.ImagePath))
{
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-image me-1"></i>Item Image</h6>
</div>
<div class="card-body p-2 text-center">
<a href="@Url.Action("Image", "CatalogItems", new { id = Model.Id, thumbnail = false })" target="_blank">
<img src="@Url.Action("Image", "CatalogItems", new { id = Model.Id, thumbnail = true })"
alt="@Model.Name"
style="max-width:100%;border-radius:6px;" />
</a>
<p class="text-muted small mt-1 mb-0">Click to view full size</p>
</div>
</div>
}
<div class="card">
<div class="card-header">
<h5 class="mb-0">Actions</h5>
@@ -14,7 +14,7 @@
</h4>
</div>
<div class="card-body">
<form asp-action="Edit" method="post">
<form asp-action="Edit" method="post" enctype="multipart/form-data">
<input type="hidden" asp-for="Id" />
<partial name="_ValidationSummary" />
@@ -159,6 +159,40 @@
<div class="form-text">When checked, this item appears in the merchandise picker on invoices and can be sold without a job.</div>
</div>
<!-- Item Image -->
<h5 class="border-bottom pb-2 mb-3 mt-4">Item Image <span class="text-muted small fw-normal">(optional)</span></h5>
@if (ViewBag.HasImage == true)
{
<div class="mb-3 d-flex align-items-start gap-3">
<div>
<img id="imagePreviewImg"
src="@Url.Action("Image", "CatalogItems", new { id = Model.Id, thumbnail = true })"
alt="Current image"
style="width:100px;height:100px;object-fit:cover;border-radius:6px;border:1px solid #dee2e6;" />
</div>
<div>
<p class="mb-1 fw-semibold">Current Image</p>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="removeImage" id="removeImage" value="true" />
<label class="form-check-label text-danger" for="removeImage">Remove current image</label>
</div>
<label for="image" class="form-label text-muted small">Replace with a new image:</label>
<input type="file" class="form-control form-control-sm" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
</div>
</div>
}
else
{
<div class="mb-3">
<label for="image" class="form-label fw-semibold">Upload Image</label>
<input type="file" class="form-control" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp — max 10 MB. A 200×200 thumbnail is generated automatically.</div>
<div id="imagePreview" class="mt-2 d-none">
<img id="imagePreviewImg" src="" alt="Preview" style="max-width:200px;max-height:200px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;" />
</div>
</div>
}
<!-- Actions -->
<div class="d-flex justify-content-between mt-4">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
@@ -345,5 +379,20 @@
}
}
});
function previewCatalogImage(input) {
const preview = document.getElementById('imagePreview');
const img = document.getElementById('imagePreviewImg');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = e => {
img.src = e.target.result;
if (preview) preview.classList.remove('d-none');
};
reader.readAsDataURL(input.files[0]);
} else {
if (preview) preview.classList.add('d-none');
}
}
</script>
}
@@ -22,7 +22,12 @@
<script src="~/js/catalog.js"></script>
}
<div class="d-flex justify-content-end align-items-center mb-4">
<div class="d-flex justify-content-end align-items-center gap-2 mb-4">
<a asp-action="AiPriceCheck" class="btn btn-outline-primary text-nowrap">
<i class="bi bi-robot me-2"></i>
<span class="d-none d-sm-inline">AI Price Check</span>
<span class="d-inline d-sm-none">AI</span>
</a>
<a asp-action="ExportCatalogPdf" class="btn btn-primary text-nowrap">
<i class="bi bi-file-pdf me-2"></i>
<span class="d-none d-sm-inline">Export Product Catalog to PDF</span>
@@ -34,9 +34,21 @@
@foreach (var item in Model.Items)
{
<div class="item-row">
<div class="item-row-name">
<div class="item-row-name d-flex align-items-center gap-2">
@if (!string.IsNullOrEmpty(item.ThumbnailPath))
{
<img src="@Url.Action("Image", "CatalogItems", new { id = item.Id, thumbnail = true })"
alt="@item.Name"
style="width:40px;height:40px;object-fit:cover;border-radius:4px;flex-shrink:0;" />
}
else
{
<span style="width:40px;height:40px;background:#f0f0f0;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="bi bi-image catalog-text-muted"></i>
</span>
}
<a asp-action="Details" asp-route-id="@item.Id" class="catalog-item-link">
<i class="bi bi-box me-2 catalog-text-muted"></i>@item.Name
@item.Name
</a>
</div>
<div class="item-row-meta">
@@ -10,10 +10,14 @@
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="d-flex justify-content-end mb-4">
<div class="d-flex justify-content-between mb-4">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to List
</a>
<a asp-controller="SubscriptionManagement" asp-action="Manage" asp-route-id="@Model.Id"
class="btn btn-outline-info">
<i class="bi bi-credit-card me-1"></i>Subscription &amp; Features
</a>
</div>
<div class="card shadow-sm">
@@ -145,30 +149,6 @@
</div>
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">AI Features</h5>
<p class="text-muted small mb-3">Control which AI-powered features are available to this company and set monthly usage limits.</p>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="form-check form-switch">
<input asp-for="AiPhotoQuotesEnabled" class="form-check-input" type="checkbox" />
<label asp-for="AiPhotoQuotesEnabled" class="form-check-label fw-medium">AI Photo Quotes</label>
</div>
<div class="form-text">Allow this company to use photo-based AI quoting.</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input asp-for="AiInventoryAssistEnabled" class="form-check-input" type="checkbox" />
<label asp-for="AiInventoryAssistEnabled" class="form-check-label fw-medium">AI Inventory Assist</label>
</div>
<div class="form-text">Allow this company to use AI lookup on inventory items.</div>
</div>
<div class="col-md-4">
<label asp-for="MaxAiPhotoQuotesPerMonthOverride" class="form-label">Monthly AI Quote Limit Override</label>
<input asp-for="MaxAiPhotoQuotesPerMonthOverride" type="number" class="form-control" min="-1" placeholder="Leave blank to use plan default" />
<div class="form-text">-1 = unlimited. 0 = disabled. Blank = use subscription plan default.</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
@@ -184,6 +184,10 @@
class="btn btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a asp-controller="SubscriptionManagement" asp-action="Manage" asp-route-id="@company.Id"
class="btn btn-outline-info" title="Manage Subscription & Features">
<i class="bi bi-credit-card"></i>
</a>
<form asp-action="ToggleActive" asp-route-id="@company.Id"
method="post" class="d-inline">
@Html.AntiForgeryToken()
@@ -430,6 +430,51 @@
</div>
</section>
<section id="ai-price-check" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-robot text-primary me-2"></i>AI Catalog Price Check
</h2>
<p>
The AI Price Check reviews every active, priced item in your
<a asp-controller="CatalogItems" asp-action="Index">Catalog Items</a> list against your
shop's actual operating costs. It estimates a realistic surface area and processing time
for each item, calculates a cost floor, and compares that to your current price — flagging
anything that may be losing money, leaving margin on the table, or priced above market rates.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Verdicts</h3>
<ul class="mb-3">
<li class="mb-2"><strong>Below Cost</strong> — price is at or below the estimated cost floor. The shop loses money on every sale of this item.</li>
<li class="mb-2"><strong>Thin Margin</strong> — price covers costs but falls below your target margin percentage.</li>
<li class="mb-2"><strong>High</strong> — price appears significantly above typical market rates, which may cost you work.</li>
<li class="mb-2"><strong>OK</strong> — price is within a reasonable range given your costs and market context.</li>
</ul>
<h3 class="h6 fw-semibold mt-3 mb-2">How to run it</h3>
<ol class="mb-3">
<li class="mb-2">Make sure your <a asp-controller="CompanySettings" asp-action="Index">operating costs</a> are up to date — stale rates produce inaccurate verdicts.</li>
<li class="mb-2">Go to <a asp-controller="CatalogItems" asp-action="Index">Catalog Items</a> and click <strong>AI Price Check</strong> in the top-right.</li>
<li class="mb-2">Click <strong>Analyze Catalog with AI</strong>. A progress overlay appears while the analysis runs (allow 710 minutes for large catalogs).</li>
<li class="mb-2">Review results sorted by severity — Below Cost items appear first. Click <strong>Edit Price</strong> on any item to update it directly from the results page.</li>
</ol>
<h3 class="h6 fw-semibold mt-3 mb-2">Things to know</h3>
<ul class="mb-3">
<li class="mb-2"><strong>Run limit:</strong> Analysis can be run once per quarter (90 days). The button shows the next available date when a recent run exists.</li>
<li class="mb-2"><strong>Confidence levels:</strong> Each result shows High, Medium, or Low confidence. Vague item names like "Custom Part" will be Low — verify those manually.</li>
<li class="mb-2"><strong>Category paths matter:</strong> The AI uses the full category path (e.g. "Cerakote &rsaquo; Firearms") to determine the coating type. Make sure specialty items are in the correct category.</li>
<li class="mb-2"><strong>$0 items skipped:</strong> Placeholder items and category headers with no price are automatically excluded from analysis.</li>
</ul>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
Results are estimates based on industry knowledge and your shop's rates. Always apply
your own judgment before changing prices — especially for items flagged as Low confidence.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@@ -449,6 +494,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#powder-insights">Powder Insights</a>
<a class="nav-link py-1 px-3 small text-body" href="#inventory-categories">Inventory Categories &amp; Is Coating</a>
<a class="nav-link py-1 px-3 small text-body" href="#powder-usage">Powder Usage on Jobs</a>
<a class="nav-link py-1 px-3 small text-body" href="#ai-price-check">AI Catalog Price Check</a>
</nav>
</div>
</div>
+45 -1
View File
@@ -229,7 +229,7 @@
one-off work that does not fit the standard calculation model.
</li>
<li class="mb-2">
<strong>AI Photo Quote Item</strong> — upload photos of the parts and let AI (Claude) estimate
<strong>AI Photo Quote Item</strong> — upload photos of the parts and let our AI agent estimate
the surface area, complexity, and labor time. Review and override any value before accepting.
Up to two follow-up rounds of questions are supported.
</li>
@@ -576,6 +576,49 @@
</div>
</section>
<section id="work-order-qr-codes" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-qr-code text-primary me-2"></i>Work Order QR Codes
</h2>
<p>
Every printed job work order includes two tiers of QR codes — one for <strong>viewing</strong>
the job and a separate set for <strong>acting</strong> on it. This gives shop workers everything
they need from a printed sheet without touching the desktop app.
All QR codes require a logged-in account.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-eye me-1"></i>Top QR — View Job</h3>
<p>
Located in the work order header, next to the job number. Scan it with your phone to open the
full <strong>Job Details</strong> page — items, catalog product images, powder specs, coatings,
prep services, and special instructions. Use it to verify you're working the right job or to
see catalog item images on your phone without hunting through the app.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-arrow-right-circle me-1"></i>Bottom QR — Update Status</h3>
<p>
Scan to open a mobile-friendly status bump page for this job. Tap the button to advance to the
next stage (or put the job on hold). The status change is recorded in history with your name —
no anonymous bumps.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-box-seam me-1"></i>Bottom QR — Log Powder Usage</h3>
<p>
One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled
with that powder and the job number, so you can record actual lbs used in seconds without
navigating through the app.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lock flex-shrink-0 mt-1"></i>
<div>
<strong>Login required:</strong> All three QR codes require workers to be logged in to their
account. Logging in once on their phone is enough for the session. Make sure every shop
floor worker has an account set up before handing out printed work orders.
</div>
</div>
</section>
<section id="blank-work-order" class="mb-5">
<h2 class="h5 fw-semibold mb-3">Blank Work Order</h2>
<p>
@@ -643,6 +686,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#part-intake">Part Intake</a>
<a class="nav-link py-1 px-3 small text-body" href="#shop-mobile">Shop Mobile</a>
<a class="nav-link py-1 px-3 small text-body" href="#changing-customer">Changing the Customer</a>
<a class="nav-link py-1 px-3 small text-body" href="#work-order-qr-codes">Work Order QR Codes</a>
<a class="nav-link py-1 px-3 small text-body" href="#blank-work-order">Blank Work Order</a>
</nav>
</div>
@@ -123,6 +123,23 @@
</div>
</div>
<h3 class="h6 fw-semibold mt-3 mb-2">Selecting a Product from Catalog</h3>
<p>
When you choose the <strong>Product from Catalog</strong> item type, the wizard shows a scrollable
list of all your active catalog items with a search box at the top. Start typing any part of the
item name, SKU, or category to filter the list instantly.
</p>
<p>
If an image has been uploaded for a catalog item, a small thumbnail appears to the left of its
name in the list. <strong>Hover over the thumbnail</strong> to see a larger preview near your
cursor — useful for quickly confirming you have the right part without opening the full item record.
</p>
<p>
Images are managed on the <a href="/CatalogItems">Catalog Items</a> page — open any item, click
<strong>Edit</strong>, and use the <strong>Item Image</strong> section to upload a photo
(jpg, jpeg, png, gif, or webp; max 10 MB). A 200&times;200 thumbnail is generated automatically.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Coatings and Prep Services</h3>
<p>
For Calculated and AI Photo items, after entering the surface area you proceed to the coatings
@@ -176,7 +176,7 @@
<i class="bi bi-robot text-primary me-2"></i>AI-Powered Reports
</h2>
<p>
Several reports use AI (Claude by Anthropic) to analyze your data and return insights in plain
Several reports use AI to analyze your data and return insights in plain
English. These are found either on the Reports landing page or as buttons within other reports.
</p>
@@ -169,6 +169,64 @@
</div>
</section>
<section id="passkeys" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-fingerprint text-primary me-2"></i>Passkeys &amp; Biometric Login
</h2>
<p>
Passkeys let you sign in using your device's built-in biometrics — Face ID or Touch ID on iPhone and Mac,
fingerprint or face unlock on Android, or Windows Hello on a PC — without ever typing your password.
This is especially useful for shop floor workers who may have dirty or gloved hands.
</p>
<h5 class="fw-semibold mt-3 mb-2">Setting Up a Passkey</h5>
<ol class="mb-3">
<li class="mb-1">Log in with your password as normal.</li>
<li class="mb-1">
A prompt appears in the bottom-right corner of the screen after login. Click
<strong>Enable</strong> and follow the device prompt (Face ID, fingerprint, Windows Hello PIN, etc.).
</li>
<li class="mb-1">The passkey is saved to that device. Repeat on each device you want to use biometrics on.</li>
</ol>
<p>
Alternatively, go to <a href="/Passkey/Manage">Passkeys &amp; Biometrics</a> from the user
menu (top-right) at any time to add a new passkey for the current device.
</p>
<h5 class="fw-semibold mt-3 mb-2">Signing In with a Passkey</h5>
<ol class="mb-3">
<li class="mb-1">Open the login page.</li>
<li class="mb-1">
Click the <strong>Use Face ID / Biometric</strong> button (the label matches your device —
"Use Windows Hello", "Use Touch ID", etc.).
</li>
<li class="mb-1">Follow the device prompt. You are signed in immediately — no password required.</li>
</ol>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-3" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
The biometric button only appears if your browser and device support passkeys
(Safari 16+, Chrome 108+, Edge 108+, or any modern Android browser over HTTPS).
On unsupported browsers it is hidden automatically.
</div>
</div>
<h5 class="fw-semibold mt-3 mb-2">Managing Passkeys</h5>
<p>
Go to <a href="/Passkey/Manage">Passkeys &amp; Biometrics</a> (user menu → Passkeys &amp; Biometrics)
to see all devices you have enrolled. Each entry shows the device name, the date it was added,
and when it was last used. Click <strong>Remove</strong> to revoke a passkey from a specific device —
useful if you lose a phone or change devices.
</p>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
Removing a passkey does not log you out — it just means that device will require a password
on the next login. If you lose a device, remove its passkey here as soon as possible.
</div>
</div>
</section>
<section id="appearance" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-palette text-primary me-2"></i>Appearance
@@ -224,6 +282,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#changing-password">Changing Your Password</a>
<a class="nav-link py-1 px-3 small text-body" href="#profile-photo">Profile Photo</a>
<a class="nav-link py-1 px-3 small text-body" href="#two-factor-auth">Two-Factor Auth</a>
<a class="nav-link py-1 px-3 small text-body" href="#passkeys">Passkeys &amp; Biometrics</a>
<a class="nav-link py-1 px-3 small text-body" href="#appearance">Appearance</a>
</nav>
</div>
@@ -3,7 +3,7 @@
Layout = null;
var job = ViewBag.Job as PowderCoating.Core.Entities.Job;
var allStatuses = ViewBag.AllStatuses as List<PowderCoating.Core.Entities.JobStatusLookup>;
var token = (Guid)ViewBag.Token;
var jobId = (int)ViewBag.JobId;
// Determine next/previous status options
var currentOrder = job!.JobStatus.DisplayOrder;
@@ -240,7 +240,7 @@
@* On hold — offer resume (next logical status after resume by advancing) *@
@if (nextStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-token="@token">
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
<button type="submit" class="btn-resume">
@@ -254,7 +254,7 @@
@* Advance to next step *@
@if (nextStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-token="@token">
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@nextStatus.Id" />
<button type="submit" class="btn-advance">
@@ -270,7 +270,7 @@
@* On Hold option *@
@if (onHoldStatus != null)
{
<form method="post" asp-action="StatusBump" asp-route-token="@token">
<form method="post" asp-action="StatusBump" asp-route-id="@jobId">
@Html.AntiForgeryToken()
<input type="hidden" name="newStatusId" value="@onHoldStatus.Id" />
<button type="submit" class="btn-hold">
@@ -292,22 +292,33 @@
</div>
<div class="col-6">
<div class="work-order-title">WORK ORDER</div>
<div class="text-center" style="font-size: 8pt; line-height: 1.6;">
<div class="mb-2">
<span class="text-muted">Job #:</span>
<span style="font-size: 14pt;" class="fw-bold ms-1">@Model.JobNumber</span>
</div>
<div class="d-flex justify-content-center align-items-center gap-3 mb-1">
<div>
<span class="text-muted">Priority:</span>
<span class="badge bg-@Model.PriorityColorClass ms-1">@Model.PriorityDisplayName</span>
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="font-size: 8pt; line-height: 1.6; flex: 1; text-align: center;">
<div class="mb-2">
<span class="text-muted">Job #:</span>
<span style="font-size: 14pt;" class="fw-bold ms-1">@Model.JobNumber</span>
</div>
<div>
<span class="text-muted">Status:</span>
<span class="badge bg-@Model.StatusColorClass ms-1">@Model.StatusDisplayName</span>
<div class="d-flex justify-content-center align-items-center gap-3 mb-1">
<div>
<span class="text-muted">Priority:</span>
<span class="badge bg-@Model.PriorityColorClass ms-1">@Model.PriorityDisplayName</span>
</div>
<div>
<span class="text-muted">Status:</span>
<span class="badge bg-@Model.StatusColorClass ms-1">@Model.StatusDisplayName</span>
</div>
</div>
<div class="text-center text-muted" style="font-size: 7pt;">Created: @Model.CreatedAt.ToString("MM/dd/yyyy")</div>
</div>
<div class="text-center text-muted" style="font-size: 7pt;">Created: @Model.CreatedAt.ToString("MM/dd/yyyy")</div>
@if (ViewBag.ViewQrCodeBase64 != null)
{
<div style="text-align: center; flex-shrink: 0;">
<img src="data:image/png;base64,@ViewBag.ViewQrCodeBase64"
alt="View Job"
style="width: 64px; height: 64px; image-rendering: pixelated; display: block;" />
<div style="font-size: 6.5pt; color: #6c757d; margin-top: 2px;">View Job</div>
</div>
}
</div>
</div>
</div>
@@ -605,7 +616,7 @@
<i class="bi bi-arrow-right-circle me-1"></i>Update Status
</div>
<div style="font-size: 7.5pt; color: #6c757d; line-height: 1.5;">
Advance job to next<br />status — no login required.
Advance job to<br />next status.
</div>
</div>
</div>
@@ -498,7 +498,7 @@
<div id="aiLoading" class="d-none p-4 text-center">
<div class="spinner-border text-purple mb-3" style="color:#6f42c1; width:3rem; height:3rem;"></div>
<div class="fw-semibold">Analyzing your queue...</div>
<div class="text-muted small mt-1">Claude is grouping jobs by color, temperature, and priority</div>
<div class="text-muted small mt-1">AI is grouping jobs by color, temperature, and priority</div>
</div>
<!-- Error state -->
@@ -534,7 +534,7 @@
<!-- Initial state (before running) -->
<div id="aiInitial" class="p-4">
<p class="text-muted small">
Claude will analyze all <strong>@Model.QueuedJobs.Count job(s)</strong> in the queue and suggest
Our AI agent will analyze all <strong>@Model.QueuedJobs.Count job(s)</strong> in the queue and suggest
optimized batches for your <strong>@Model.Ovens.Count oven(s)</strong>, grouping by:
</p>
<ul class="small text-muted">
@@ -0,0 +1,150 @@
@{
ViewData["Title"] = "Enable Biometric Login";
Layout = "/Views/Shared/_AuthLayout.cshtml";
var returnUrl = ViewBag.ReturnUrl as string ?? "/";
}
@section Styles {
<style>
.auth-brand-panel {
background: linear-gradient(135deg, #1a1a2e, #16213e, #0f3460);
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 3rem 2.5rem;
color: white;
}
.auth-brand-panel h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.75rem;
text-align: center;
}
.auth-brand-panel .tagline {
font-size: 1rem;
color: rgba(255,255,255,0.65);
margin-bottom: 2.5rem;
text-align: center;
}
.feature-list {
list-style: none;
padding: 0;
width: 100%;
max-width: 280px;
}
.feature-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0;
font-size: 0.95rem;
color: rgba(255,255,255,0.82);
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.feature-list li:last-child { border-bottom: none; }
.feature-list li i {
color: #4fc3f7;
font-size: 1.1rem;
flex-shrink: 0;
}
.auth-form-panel {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2.5rem 1.5rem;
background-color: #ffffff;
min-height: 100vh;
}
.auth-form-container {
width: 100%;
max-width: 420px;
}
.auth-form-container h2 {
font-size: 1.75rem;
font-weight: 700;
color: #0f172a;
}
.auth-form-container .subtext {
color: #64748b;
font-size: 0.95rem;
}
</style>
}
<div class="d-flex" style="min-height:100vh;">
<!-- Left brand panel — hidden on mobile, same as login page -->
<div class="col-lg-5 d-none d-lg-flex auth-brand-panel">
<img src="/images/pcl-logo.png" alt="Powder Coating Logix" style="max-width:220px; margin-bottom:1.5rem;" />
<h1>Powder Coating Logix</h1>
<p class="tagline">The complete management platform for powder coating businesses</p>
<ul class="feature-list">
<li><i class="bi bi-fingerprint"></i> Fast biometric login</li>
<li><i class="bi bi-shield-check-fill"></i> Secure — your biometrics never leave your device</li>
<li><i class="bi bi-phone-fill"></i> Works with Face ID, Touch ID &amp; Windows Hello</li>
<li><i class="bi bi-trash3-fill"></i> Remove any device at any time</li>
</ul>
</div>
<!-- Right prompt panel -->
<div class="auth-form-panel col-lg-7">
<div class="auth-form-container text-center">
<div id="prompt-step">
<div class="mb-4" style="font-size:4rem; color:#0284c7; line-height:1;">
<i class="bi bi-fingerprint"></i>
</div>
<h2 class="mb-2">Speed up future logins</h2>
<p class="subtext mb-4">
Enable biometric login so next time you can sign in with a single tap —
no password needed.
</p>
<p id="pk-status" class="small mb-3" style="min-height:1.25em;"></p>
<div class="d-grid gap-2" style="max-width:320px; margin:0 auto;">
<button id="pk-enable-btn" type="button" class="btn btn-primary btn-lg">
<i class="bi bi-fingerprint me-2"></i><span id="pk-btn-label">Enable Biometric Login</span>
</button>
<a id="pk-skip-link" href="@returnUrl" class="btn btn-outline-secondary btn-lg">
Maybe later
</a>
<form method="post" action="/Passkey/DismissPrompt">
@Html.AntiForgeryToken()
<input type="hidden" name="returnUrl" value="@returnUrl" />
<button type="submit" class="btn btn-link text-muted w-100" style="font-size:0.85rem;">
Don't ask me again
</button>
</form>
</div>
</div>
<div id="success-step" class="d-none">
<div class="mb-4" style="font-size:4rem; color:#16a34a; line-height:1;">
<i class="bi bi-check-circle-fill"></i>
</div>
<h2 class="mb-2">All set!</h2>
<p class="subtext mb-4">Biometric login is enabled for this device.</p>
<a href="@returnUrl" class="btn btn-primary btn-lg px-5">Continue</a>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/passkey.js"></script>
<script src="~/js/passkey-enroll.js"></script>
}
@@ -0,0 +1,97 @@
@model IEnumerable<PowderCoating.Core.Entities.UserPasskey>
@{
ViewData["Title"] = "My Passkeys";
}
<div class="container-fluid py-4" style="max-width:760px;">
<div class="d-flex align-items-center gap-3 mb-4">
<div class="rounded-circle d-flex align-items-center justify-content-center"
style="width:48px;height:48px;background:#e0f2fe;">
<i class="bi bi-fingerprint" style="font-size:1.5rem;color:#0284c7;"></i>
</div>
<div>
<h4 class="mb-0 fw-semibold">Passkeys &amp; Biometric Login</h4>
<p class="text-muted small mb-0">
Passkeys let you sign in with Face ID, fingerprint, or your device PIN — no password needed.
</p>
</div>
</div>
@if (TempData["Success"] is string msg)
{
<div class="alert alert-success alert-permanent">
<i class="bi bi-check-circle-fill me-2"></i>@msg
</div>
}
<!-- Add new passkey -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<h6 class="card-title mb-1">Add a passkey for this device</h6>
<p class="text-muted small mb-3">
You'll be prompted to authenticate using Face ID, Touch ID, Windows Hello, or a security key.
</p>
<div class="d-flex gap-2 align-items-center flex-wrap">
<input type="text" id="pk-device-name" class="form-control" style="max-width:220px;"
placeholder="Device name (e.g. iPhone 15)" maxlength="64" />
<button type="button" id="pk-add-btn" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Add Passkey
</button>
</div>
<p id="pk-add-status" class="mt-2 small mb-0"></p>
</div>
</div>
<!-- Existing passkeys -->
@if (!Model.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-fingerprint" style="font-size:3rem;opacity:.3;"></i>
<p class="mt-3">No passkeys registered yet.<br />Add one above to enable biometric login on this device.</p>
</div>
}
else
{
<div class="list-group shadow-sm">
@foreach (var pk in Model)
{
<div class="list-group-item list-group-item-action d-flex align-items-center gap-3">
<i class="bi bi-phone" style="font-size:1.4rem;color:#64748b;flex-shrink:0;"></i>
<div class="flex-grow-1 min-width-0">
<div class="fw-medium text-truncate">
@(pk.DeviceFriendlyName ?? "Unnamed device")
</div>
<div class="text-muted small">
Added @pk.CreatedAt.ToLocalTime().ToString("MMM d, yyyy")
@if (pk.LastUsedAt.HasValue)
{
<span class="ms-2">&bull; Last used @pk.LastUsedAt.Value.ToLocalTime().ToString("MMM d, yyyy")</span>
}
</div>
</div>
<form method="post" asp-action="Remove" asp-route-id="@pk.Id"
onsubmit="return confirm('Remove this passkey?');">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash3"></i> Remove
</button>
</form>
</div>
}
</div>
<p class="text-muted small mt-3">
Removing a passkey means you'll need to use your password on that device next time.
</p>
}
<div class="mt-4">
<a asp-controller="CompanySettings" asp-action="Index" class="text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Back to Settings
</a>
</div>
</div>
@section Scripts {
<script src="~/js/passkey.js"></script>
<script src="~/js/passkey-manage.js"></script>
}
@@ -144,7 +144,7 @@
</div>
</div>
<div class="mb-4">
<div class="mb-3">
<div class="form-check form-switch">
<input asp-for="AllowAiInventoryAssist" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="AllowAiInventoryAssist" class="form-check-label fw-medium">Allow AI Inventory Assist</label>
@@ -154,6 +154,16 @@
</div>
</div>
<div class="mb-4">
<div class="form-check form-switch">
<input asp-for="AllowAiCatalogPriceCheck" class="form-check-input" type="checkbox" role="switch" />
<label asp-for="AllowAiCatalogPriceCheck" class="form-check-label fw-medium">Allow AI Catalog Price Check</label>
</div>
<div class="form-text">
When enabled, companies on this plan can run AI-powered catalog price analysis (once per quarter).
</div>
</div>
<h5 class="mb-3 pb-2 border-bottom mt-4">Stripe Integration</h5>
<div class="alert alert-info small mb-3" role="alert">
@@ -156,6 +156,19 @@
}
</td>
</tr>
<tr>
<td class="text-muted">AI Price Check</td>
<td>
@if (plan.AllowAiCatalogPriceCheck)
{
<span class="badge bg-success">Enabled</span>
}
else
{
<span class="badge bg-secondary">Disabled</span>
}
</td>
</tr>
<tr class="table-light">
<td colspan="2" class="fw-semibold small text-uppercase text-muted py-1">Stripe</td>
</tr>
@@ -77,7 +77,7 @@
<div id="flagsList"></div>
<div class="text-muted small text-end mt-3">
<i class="bi bi-robot me-1"></i>Generated by Claude AI &middot; <span id="analysisTimestamp"></span>
<i class="bi bi-robot me-1"></i>Generated by AI &middot; <span id="analysisTimestamp"></span>
&middot; <a href="#" onclick="runAnalysis(); return false;">Re-run</a>
</div>
</div>
@@ -140,7 +140,7 @@
</div>
<div class="text-muted small text-end">
<i class="bi bi-robot me-1"></i>Generated by Claude AI &middot; <span id="forecastTimestamp"></span>
<i class="bi bi-robot me-1"></i>Generated by AI &middot; <span id="forecastTimestamp"></span>
&middot; <a href="#" onclick="runForecast(); return false;">Refresh</a>
</div>
</div>
@@ -1487,6 +1487,7 @@
}
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" asp-controller="Profile" asp-action="Index"><i class="bi bi-person me-2"></i>Profile</a></li>
<li><a class="dropdown-item" asp-controller="Passkey" asp-action="Manage"><i class="bi bi-fingerprint me-2"></i>Passkeys &amp; Biometrics</a></li>
<li><a class="dropdown-item" asp-controller="TwoFactorSetup" asp-action="Index"><i class="bi bi-shield-lock me-2"></i>Two-Factor Auth</a></li>
<li><a class="dropdown-item" asp-controller="ReleaseNotes" asp-action="Index"><i class="bi bi-rocket-takeoff me-2"></i>What's New</a></li>
<li><a class="dropdown-item" asp-controller="Help" asp-action="Index"><i class="bi bi-question-circle me-2"></i>Help</a></li>
@@ -2091,6 +2092,7 @@
{
@* @await Html.PartialAsync("_AiQuickQuoteWidget") *@
@await Html.PartialAsync("_AiHelpWidget")
<script src="~/js/passkey.js"></script>
}
<!-- ── Quick-Add Modal (reusable inline form host) ─────────────────────── -->
@@ -25,6 +25,10 @@
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<a asp-controller="Companies" asp-action="Edit" asp-route-id="@Model.Id"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-building me-1"></i>Edit Company
</a>
<h4 class="mb-0">
<i class="bi bi-credit-card me-2 text-primary"></i>@Model.CompanyName
</h4>
@@ -293,6 +297,15 @@
</div>
<div class="form-text">Allow AI-powered inventory lookups.</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
name="aiCatalogPriceCheckEnabled" value="true" id="aiCatalogPriceCheck"
@(Model.AiCatalogPriceCheckEnabled ? "checked" : "") />
<label class="form-check-label" for="aiCatalogPriceCheck">AI Catalog Price Check</label>
</div>
<div class="form-text">Override: grants access regardless of plan tier.</div>
</div>
<div class="col-md-4">
<label class="form-label small fw-medium">AI Photo Quotes / Month Override</label>
<input type="number" name="maxAiPhotoQuotesPerMonthOverride" class="form-control form-control-sm"
+2 -1
View File
@@ -75,7 +75,8 @@
"JobImages": "jobimages",
"Manuals": "manuals",
"CompanyLogos": "companylogos",
"ReceiptImages": "receiptimages"
"ReceiptImages": "receiptimages",
"CatalogImages": "catalogimages"
}
}
}
@@ -0,0 +1,86 @@
(function () {
'use strict';
var form = document.getElementById('runForm');
var btn = document.getElementById('runBtn');
var overlay = document.getElementById('price-check-overlay');
var bar = document.getElementById('overlay-bar');
var pctLabel = document.getElementById('overlay-pct');
var statusMsg = document.getElementById('overlay-status');
if (!form || !btn || !overlay) return;
// Estimate total seconds based on item count.
// Haiku sequential: 1 batch at a time, ~27s each (API time + 20s pacing gap).
function estimateDuration(itemCount) {
var batches = Math.max(1, Math.ceil(itemCount / 25));
return Math.max(30, batches * 27);
}
// Messages keyed to approximate progress milestones (0100).
function messageAt(pct, batchCount) {
if (pct < 10) return 'Loading catalog items…';
if (pct < 20) return 'Sending items for analysis…';
if (batchCount <= 1) {
if (pct < 75) return 'Analyzing your catalog with AI…';
if (pct < 92) return 'Reviewing pricing data…';
} else {
var batchDone = Math.floor((pct / 100) * batchCount);
if (batchDone < batchCount) {
return 'Analyzing batch ' + (batchDone + 1) + ' of ' + batchCount + '…';
}
}
if (pct < 97) return 'Compiling results…';
return 'Almost done…';
}
function setProgress(pct, batchCount) {
var clamped = Math.min(99, Math.max(0, pct));
bar.style.width = clamped + '%';
pctLabel.textContent = Math.round(clamped);
statusMsg.textContent = messageAt(clamped, batchCount);
}
form.addEventListener('submit', function () {
btn.disabled = true;
var itemCount = parseInt(btn.getAttribute('data-item-count') || '0', 10);
var batchCount = Math.max(1, Math.ceil(itemCount / 25));
var totalSecs = estimateDuration(itemCount);
overlay.classList.add('active');
setProgress(0, batchCount);
// Animate progress: fast to ~85%, then slow crawl toward 99%.
// Uses two easing phases so it never "finishes" before the server responds.
var start = Date.now();
var phase1End = totalSecs * 0.80 * 1000; // 80% of time -> 85% progress
var raf;
function tick() {
var elapsed = Date.now() - start;
var pct;
if (elapsed < phase1End) {
// Phase 1: ease-out from 0 -> 85
var t = elapsed / phase1End;
pct = 85 * (1 - Math.pow(1 - t, 2));
} else {
// Phase 2: slow crawl 85 -> 99 (never quite reaches 99)
var t2 = (elapsed - phase1End) / (totalSecs * 1000);
pct = 85 + 14 * (1 - Math.exp(-t2 * 1.5));
}
setProgress(pct, batchCount);
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
// The page navigation itself tears down the overlay; cancel the RAF to avoid
// running after the page is gone.
window.addEventListener('pagehide', function () {
cancelAnimationFrame(raf);
});
});
}());
@@ -341,9 +341,19 @@ function renderStep2Html() {
}
function renderProductFields() {
const catalogItems = catalogData.map(c =>
`<div class="catalog-list-item px-3 py-2" data-value="${c.value}" onclick="pickCatalogItem(this)">${escHtml(c.text)}</div>`
).join('');
ensureCatalogPreviewEl();
const catalogItems = catalogData.map(c => {
const thumbHtml = c.thumbnailPath
? `<img src="/CatalogItems/Image?id=${c.value}&thumbnail=true" alt=""
style="width:36px;height:36px;object-fit:cover;border-radius:4px;flex-shrink:0;cursor:zoom-in;"
onmouseenter="showCatalogPreview(event,'/CatalogItems/Image?id=${c.value}&thumbnail=true')"
onmousemove="moveCatalogPreview(event)"
onmouseleave="hideCatalogPreview()" />`
: `<span style="width:36px;height:36px;background:#f0f0f0;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;"><i class='bi bi-image text-muted' style='font-size:.85rem;'></i></span>`;
// Inner div carries the flex layout — the outer catalog-list-item div must stay a plain block element
// so filterCatalog() can set el.style.display='none' without Bootstrap d-flex !important overriding it.
return `<div class="catalog-list-item px-2 py-2" data-value="${c.value}" onclick="pickCatalogItem(this)"><div style="display:flex;align-items:center;gap:0.5rem;">${thumbHtml}<span>${escHtml(c.text)}</span></div></div>`;
}).join('');
return `
<div class="mb-3">
@@ -385,6 +395,49 @@ function pickCatalogItem(el) {
document.getElementById('err_catalogItemId')?.classList.add('d-none');
}
// ── Catalog thumbnail hover preview ──────────────────────────────────────────
function ensureCatalogPreviewEl() {
if (document.getElementById('catalogThumbPreview')) return;
const el = document.createElement('div');
el.id = 'catalogThumbPreview';
el.style.cssText = 'position:fixed;display:none;z-index:9999;pointer-events:none;' +
'border:1px solid #dee2e6;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.18);' +
'background:#fff;padding:4px;';
el.innerHTML = '<img id="catalogThumbPreviewImg" style="display:block;width:200px;height:200px;object-fit:contain;border-radius:4px;" />';
document.body.appendChild(el);
}
function showCatalogPreview(event, url) {
const preview = document.getElementById('catalogThumbPreview');
const img = document.getElementById('catalogThumbPreviewImg');
if (!preview || !img) return;
img.src = url;
_placeCatalogPreview(event, preview);
preview.style.display = 'block';
}
function moveCatalogPreview(event) {
const preview = document.getElementById('catalogThumbPreview');
if (preview && preview.style.display !== 'none') _placeCatalogPreview(event, preview);
}
function hideCatalogPreview() {
const preview = document.getElementById('catalogThumbPreview');
if (preview) preview.style.display = 'none';
}
function _placeCatalogPreview(event, preview) {
const pad = 16, pw = 216, ph = 216;
let x = event.clientX + pad;
let y = event.clientY - ph / 2;
if (x + pw > window.innerWidth) x = event.clientX - pw - pad;
if (y < 8) y = 8;
if (y + ph > window.innerHeight) y = window.innerHeight - ph - 8;
preview.style.left = x + 'px';
preview.style.top = y + 'px';
}
function renderCalculatedFields() {
const areaUnit = pageMeta.areaUnit || 'sq ft';
return `
@@ -0,0 +1,11 @@
document.getElementById('togglePw').addEventListener('click', function () {
var input = document.getElementById('passwordInput');
var icon = document.getElementById('togglePwIcon');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
});
@@ -0,0 +1,52 @@
document.addEventListener('DOMContentLoaded', async () => {
const enableBtn = document.getElementById('pk-enable-btn');
const btnLabel = document.getElementById('pk-btn-label');
const statusEl = document.getElementById('pk-status');
const promptStep = document.getElementById('prompt-step');
const successStep = document.getElementById('success-step');
if (!enableBtn) return;
// Set platform-specific label
const label = passkeyLabel();
if (btnLabel) btnLabel.textContent = `Enable ${label.replace('Use ', '')}`;
const supported = await passkeySupported();
if (!supported) {
enableBtn.disabled = true;
enableBtn.textContent = 'Not supported on this browser';
if (statusEl) {
statusEl.textContent = 'Your browser does not support biometric login. You can enable it later from Settings → Passkeys & Biometrics on a supported device.';
statusEl.className = 'small mb-3 text-muted';
}
return;
}
enableBtn.addEventListener('click', async () => {
enableBtn.disabled = true;
enableBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Follow the prompt on your device…';
if (statusEl) { statusEl.textContent = ''; }
const ua = navigator.userAgent;
const deviceName = /iphone/i.test(ua) ? 'iPhone'
: /ipad/i.test(ua) ? 'iPad'
: /android/i.test(ua) ? 'Android device'
: /macintosh/i.test(ua) ? 'Mac'
: /windows/i.test(ua) ? 'Windows PC'
: 'This device';
const result = await registerPasskey(deviceName);
if (result.success) {
promptStep.classList.add('d-none');
successStep.classList.remove('d-none');
} else {
enableBtn.disabled = false;
enableBtn.innerHTML = `<i class="bi bi-fingerprint me-2"></i>Enable ${label.replace('Use ', '')}`;
if (statusEl) {
statusEl.textContent = result.error || 'Setup failed. Please try again.';
statusEl.className = 'small mb-3 text-danger';
}
}
});
});
@@ -0,0 +1,42 @@
document.addEventListener('DOMContentLoaded', async () => {
const addBtn = document.getElementById('pk-add-btn');
const statusEl = document.getElementById('pk-add-status');
const deviceNameInput = document.getElementById('pk-device-name');
if (!addBtn) return;
const supported = await passkeySupported();
if (!supported) {
addBtn.disabled = true;
addBtn.textContent = 'Not supported on this browser';
if (statusEl) {
statusEl.textContent = 'Your browser does not support passkeys. Try Safari on iOS 16+, Chrome 108+, or Edge 108+.';
statusEl.className = 'mt-2 small mb-0 text-muted';
}
return;
}
addBtn.addEventListener('click', async () => {
const name = deviceNameInput.value.trim();
addBtn.disabled = true;
addBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Follow the prompt…';
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'mt-2 small mb-0'; }
const result = await registerPasskey(name);
if (result.success) {
if (statusEl) {
statusEl.textContent = '✓ Passkey added! Reloading…';
statusEl.className = 'mt-2 small mb-0 text-success';
}
setTimeout(() => window.location.reload(), 1200);
} else {
addBtn.disabled = false;
addBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Passkey';
if (statusEl) {
statusEl.textContent = result.error || 'Setup failed. Please try again.';
statusEl.className = 'mt-2 small mb-0 text-danger';
}
}
});
});
+274
View File
@@ -0,0 +1,274 @@
/**
* passkey.js WebAuthn / FIDO2 client helpers.
*
* WebAuthn deals in raw ArrayBuffers; the server (and Fido2NetLib) serialize
* them as base64url strings. These helpers convert back and forth so the JS
* fetch payloads match what the server expects.
*/
// ─── base64url helpers ────────────────────────────────────────────────────────
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let str = '';
for (const b of bytes) str += String.fromCharCode(b);
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function base64urlToBuffer(base64url) {
const padded = base64url.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return buffer;
}
/**
* Converts server-side CredentialCreateOptions into the shape expected by
* navigator.credentials.create(). Only the fields that the WebAuthn spec
* requires as ArrayBuffer are converted everything else (rp.id, attestation,
* type strings, etc.) must stay as plain strings.
*/
function prepareCreateOptions(opts) {
return {
...opts,
challenge: base64urlToBuffer(opts.challenge),
user: {
...opts.user,
id: base64urlToBuffer(opts.user.id)
},
excludeCredentials: (opts.excludeCredentials ?? []).map(c => ({
...c,
id: base64urlToBuffer(c.id)
}))
};
}
/**
* Converts server-side AssertionOptions into the shape expected by
* navigator.credentials.get(). Only challenge and allowCredentials[].id
* need to be ArrayBuffers.
*/
function prepareGetOptions(opts) {
return {
...opts,
challenge: base64urlToBuffer(opts.challenge),
allowCredentials: (opts.allowCredentials ?? []).map(c => ({
...c,
id: base64urlToBuffer(c.id)
}))
};
}
/** Recursively convert all ArrayBuffers in a credential to base64url strings for JSON. */
function encodeCredential(obj) {
if (obj instanceof ArrayBuffer) return bufferToBase64url(obj);
if (ArrayBuffer.isView(obj)) return bufferToBase64url(obj.buffer.slice(obj.byteOffset, obj.byteOffset + obj.byteLength));
if (Array.isArray(obj)) return obj.map(encodeCredential);
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) out[k] = encodeCredential(v);
return out;
}
return obj;
}
// ─── Registration (after password login) ─────────────────────────────────────
/**
* Starts the passkey registration flow for the currently signed-in user.
* @param {string} deviceName Optional friendly name (e.g. "Scott's iPhone")
*/
async function registerPasskey(deviceName) {
try {
// 1. Ask the server for creation options (challenge)
const optRes = await fetch('/Passkey/RegisterOptions', { method: 'POST' });
if (!optRes.ok) throw new Error(await optRes.text());
const optionsRaw = await optRes.json();
// 2. Convert server options into WebAuthn API format
const options = prepareCreateOptions(optionsRaw);
// 3. Prompt the authenticator (FaceID / fingerprint / security key)
const credential = await navigator.credentials.create({ publicKey: options });
if (!credential) throw new Error('No credential returned from authenticator.');
// 4. Build the response payload (all ArrayBuffers → base64url)
const payload = {
id: bufferToBase64url(credential.rawId),
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64url(credential.response.attestationObject),
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
}
};
// 5. Send to server
const qs = deviceName ? `?deviceName=${encodeURIComponent(deviceName)}` : '';
const regRes = await fetch(`/Passkey/Register${qs}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!regRes.ok) {
const err = await regRes.json().catch(() => ({ error: regRes.statusText }));
throw new Error(err.error || 'Registration failed.');
}
return { success: true };
} catch (err) {
console.error('Passkey registration error:', err);
return { success: false, error: err.message };
}
}
// ─── Authentication (at login page) ──────────────────────────────────────────
/**
* Attempts to sign in using a registered passkey.
* Resolves with { success, redirectUrl } or { success: false, error }.
*/
async function loginWithPasskey() {
try {
// 1. Get assertion options from the server
const optRes = await fetch('/Passkey/LoginOptions', { method: 'POST' });
if (!optRes.ok) throw new Error(await optRes.text());
const optionsRaw = await optRes.json();
// 2. Convert to WebAuthn format
const options = prepareGetOptions(optionsRaw);
// 3. Prompt authenticator — browser shows passkey picker automatically
const assertion = await navigator.credentials.get({ publicKey: options });
if (!assertion) throw new Error('No credential returned.');
// 4. Build payload
const payload = {
id: bufferToBase64url(assertion.rawId),
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufferToBase64url(assertion.response.userHandle)
: null
}
};
// 5. Verify on server
const loginRes = await fetch('/Passkey/Login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!loginRes.ok) {
const err = await loginRes.json().catch(() => ({ error: loginRes.statusText }));
throw new Error(err.error || 'Login failed.');
}
const data = await loginRes.json();
return { success: true, redirectUrl: data.redirectUrl };
} catch (err) {
if (err.name === 'NotAllowedError') {
// User cancelled or timed out — not a real error
return { success: false, cancelled: true };
}
console.error('Passkey login error:', err);
return { success: false, error: err.message };
}
}
// ─── Feature detection ────────────────────────────────────────────────────────
/**
* True if the device has a user-verifying platform authenticator (Face ID,
* fingerprint, Windows Hello, etc.) that can handle our modal passkey flow.
*
* Deliberately uses isUserVerifyingPlatformAuthenticatorAvailable() rather than
* isConditionalMediationAvailable(). The conditional API signals to iOS Safari
* that the page wants autofill-style passkey interception, which causes iOS 17+
* to show its own native passkey enrollment sheet when the password form is
* submitted not what we want. The platform authenticator check simply asks
* "can this device do biometrics?" with no side-effects.
*/
async function passkeySupported() {
if (!window.PublicKeyCredential) return false;
try {
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch {
return false;
}
}
/**
* Returns a platform-appropriate label for the passkey button, e.g.
* "Use Face ID / Touch ID" on iOS, "Use Windows Hello" on Windows.
*/
function passkeyLabel() {
const ua = navigator.userAgent;
const platform = navigator.platform ?? '';
// iOS / iPadOS (iPads report MacIntel on platform in some browsers — check UA too)
if (/iphone|ipad|ipod/i.test(ua) || (/macintosh/i.test(ua) && navigator.maxTouchPoints > 1)) {
return 'Use Face ID / Touch ID';
}
// Android
if (/android/i.test(ua)) {
return 'Use Fingerprint / Face Unlock';
}
// macOS (non-touch — Touch ID on MacBook)
if (/mac/i.test(platform) || /macintosh/i.test(ua)) {
return 'Use Touch ID';
}
// Windows
if (/win/i.test(platform) || /windows/i.test(ua)) {
return 'Use Windows Hello';
}
// ChromeOS / Linux / unknown
return 'Use Passkey / Biometric';
}
// ─── Login page wiring ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
const passkeyBtn = document.getElementById('passkey-login-btn');
if (!passkeyBtn) return;
const supported = await passkeySupported();
if (!supported) {
passkeyBtn.closest('.passkey-login-section')?.remove();
return;
}
const label = passkeyLabel();
passkeyBtn.innerHTML = `<i class="bi bi-fingerprint"></i> ${label}`;
passkeyBtn.addEventListener('click', async () => {
passkeyBtn.disabled = true;
passkeyBtn.textContent = 'Waiting for authentication…';
const result = await loginWithPasskey();
if (result.success) {
window.location.href = result.redirectUrl || '/';
} else if (!result.cancelled) {
passkeyBtn.disabled = false;
passkeyBtn.innerHTML = `<i class="bi bi-fingerprint"></i> ${label}`;
const errEl = document.getElementById('passkey-error');
if (errEl) {
errEl.textContent = result.error || 'Authentication failed. Try again.';
errEl.classList.remove('d-none');
}
} else {
passkeyBtn.disabled = false;
passkeyBtn.innerHTML = `<i class="bi bi-fingerprint"></i> ${label}`;
}
});
});
@@ -0,0 +1,173 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Enums;
namespace PowderCoating.UnitTests;
public class JobPhotoServiceTests
{
[Fact]
public async Task SaveJobPhotoAsync_ReturnsError_WhenFileMissing()
{
var service = CreateService();
var result = await service.SaveJobPhotoAsync(null!, 1, 2);
Assert.False(result.Success);
Assert.Equal("No file was uploaded.", result.ErrorMessage);
}
[Fact]
public async Task SaveJobPhotoAsync_ReturnsError_WhenFileTooLarge()
{
var service = CreateService();
var file = CreateFormFile("big.jpg", 10 * 1024 * 1024 + 1);
var result = await service.SaveJobPhotoAsync(file, 1, 2);
Assert.False(result.Success);
Assert.Equal("Photo must be smaller than 10 MB.", result.ErrorMessage);
}
[Fact]
public async Task SaveJobPhotoAsync_ReturnsError_WhenExtensionNotAllowed()
{
var service = CreateService();
var file = CreateFormFile("notes.txt");
var result = await service.SaveJobPhotoAsync(file, 1, 2);
Assert.False(result.Success);
Assert.Equal("Only JPG, PNG, GIF, and WebP images are allowed.", result.ErrorMessage);
}
[Fact]
public async Task SaveJobPhotoAsync_ReturnsBlobError_WhenUploadFails()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("jobimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((false, "upload failed"));
var service = CreateService(blobService);
var result = await service.SaveJobPhotoAsync(CreateFormFile("photo.png"), 9, 7);
Assert.False(result.Success);
Assert.Equal("upload failed", result.ErrorMessage);
}
[Fact]
public async Task SaveJobPhotoAsync_UsesTenantScopedBlobPath_WhenSuccessful()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("jobimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/webp"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService);
var result = await service.SaveJobPhotoAsync(CreateFormFile("photo.webp"), 9, 7, "caption", JobPhotoType.After);
Assert.True(result.Success);
Assert.StartsWith("7/job-photos/9/", result.FilePath);
Assert.EndsWith(".webp", result.FilePath);
}
[Fact]
public async Task DeleteJobPhotoAsync_ReturnsError_WhenPathMissing()
{
var service = CreateService();
var result = await service.DeleteJobPhotoAsync(string.Empty);
Assert.False(result.Success);
Assert.Equal("File path is required.", result.ErrorMessage);
}
[Fact]
public async Task GetJobPhotoAsync_ReturnsError_WhenPathMissing()
{
var service = CreateService();
var result = await service.GetJobPhotoAsync(" ");
Assert.False(result.Success);
Assert.Equal("File path is required.", result.ErrorMessage);
Assert.Empty(result.FileContent);
}
[Fact]
public async Task JobPhotoExistsAsync_ReturnsFalse_WhenPathMissing()
{
var service = CreateService();
var result = await service.JobPhotoExistsAsync(null!);
Assert.False(result);
}
[Fact]
public async Task GetJobPhotoAsync_ProxiesBlobDownload()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.DownloadAsync("jobimages", "7/job-photos/9/photo.jpg"))
.ReturnsAsync((true, new byte[] { 1, 2 }, "image/jpeg", string.Empty));
var service = CreateService(blobService);
var result = await service.GetJobPhotoAsync("7/job-photos/9/photo.jpg");
Assert.True(result.Success);
Assert.Equal("image/jpeg", result.ContentType);
Assert.Equal(new byte[] { 1, 2 }, result.FileContent);
}
[Fact]
public async Task JobPhotoExistsAsync_UsesBlobServiceForValidPath()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ExistsAsync("jobimages", "7/job-photos/9/photo.jpg"))
.ReturnsAsync(true);
var service = CreateService(blobService);
var result = await service.JobPhotoExistsAsync("7/job-photos/9/photo.jpg");
Assert.True(result);
}
private static JobPhotoService CreateService(Mock<IAzureBlobStorageService>? blobService = null)
{
var settings = Options.Create(new StorageSettings
{
Containers = new StorageContainers
{
JobImages = "jobimages"
}
});
return new JobPhotoService(
(blobService ?? new Mock<IAzureBlobStorageService>()).Object,
settings,
Mock.Of<ILogger<JobPhotoService>>());
}
private static IFormFile CreateFormFile(string fileName, long? lengthOverride = null)
{
var dataLength = lengthOverride.HasValue
? (int)Math.Min(lengthOverride.Value, 1024)
: 16;
var bytes = Enumerable.Repeat((byte)65, dataLength).ToArray();
var stream = new MemoryStream(bytes);
return new FormFile(stream, 0, lengthOverride ?? bytes.Length, "file", fileName);
}
}
@@ -0,0 +1,62 @@
using PowderCoating.Application.Services;
namespace PowderCoating.UnitTests;
public class MeasurementConversionServiceTests
{
private readonly MeasurementConversionService _service = new();
[Fact]
public void SquareFeetToMeters_AndBack_RoundTripsToCurrencyStylePrecision()
{
var squareMeters = _service.SquareFeetToMeters(100m);
var squareFeet = _service.SquareMetersToFeet(squareMeters);
Assert.Equal(9.29m, squareMeters);
Assert.Equal(100m, squareFeet, 1);
}
[Fact]
public void PoundsToKilograms_AndBack_RoundTripsToCurrencyStylePrecision()
{
var kilograms = _service.PoundsToKilograms(10m);
var pounds = _service.KilogramsToPounds(kilograms);
Assert.Equal(4.54m, kilograms);
Assert.Equal(10.01m, pounds, 2);
}
[Fact]
public void ConvertArea_WhenImperialToMetric_UsesSquareFeetConversion()
{
var result = _service.ConvertArea(50m, fromImperial: true, toMetric: true);
Assert.Equal(4.65m, result);
}
[Fact]
public void ConvertArea_WhenSourceAndTargetAreSameSystem_ReturnsOriginalValue()
{
Assert.Equal(12.34m, _service.ConvertArea(12.34m, fromImperial: true, toMetric: false));
Assert.Equal(56.78m, _service.ConvertArea(56.78m, fromImperial: false, toMetric: true));
}
[Fact]
public void ConvertWeight_WhenMetricToImperial_UsesKilogramsToPounds()
{
var result = _service.ConvertWeight(5m, fromImperial: false, toMetric: false);
Assert.Equal(11.02m, result);
}
[Fact]
public void UnitLabelHelpers_ReturnExpectedMetricAndImperialLabels()
{
Assert.Equal("sq ft", _service.GetAreaUnitLabel(false));
Assert.Equal("sq m", _service.GetAreaUnitLabel(true));
Assert.Equal("lb", _service.GetWeightUnitLabel(false));
Assert.Equal("kg", _service.GetWeightUnitLabel(true));
Assert.Equal("sq ft/lb", _service.GetCoverageUnitLabel(false));
Assert.Equal("sq m/kg", _service.GetCoverageUnitLabel(true));
}
}
@@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Services;
namespace PowderCoating.UnitTests;
public class PlatformSettingsServiceTests
{
[Fact]
public async Task GetAsync_ReturnsStoredValue()
{
await using var context = CreateContext();
context.PlatformSettings.Add(new PlatformSetting
{
Key = "BrandName",
Value = "Powder Coating Pro"
});
await context.SaveChangesAsync();
var service = new PlatformSettingsService(context);
var value = await service.GetAsync("BrandName");
Assert.Equal("Powder Coating Pro", value);
}
[Fact]
public async Task GetAsync_WhenMissing_ReturnsNull()
{
await using var context = CreateContext();
var service = new PlatformSettingsService(context);
var value = await service.GetAsync("MissingKey");
Assert.Null(value);
}
[Fact]
public async Task SetAsync_WhenSettingExists_UpdatesValueAndAuditFields()
{
await using var context = CreateContext();
context.PlatformSettings.Add(new PlatformSetting
{
Key = "TrialsEnabled",
Value = "true",
UpdatedBy = "old-user"
});
await context.SaveChangesAsync();
var service = new PlatformSettingsService(context);
await service.SetAsync("TrialsEnabled", "false", "superadmin@example.com");
var setting = await context.PlatformSettings.SingleAsync();
Assert.Equal("false", setting.Value);
Assert.Equal("superadmin@example.com", setting.UpdatedBy);
Assert.True(setting.UpdatedAt.HasValue);
}
[Fact]
public async Task SetAsync_WhenSettingMissing_InsertsRow()
{
await using var context = CreateContext();
var service = new PlatformSettingsService(context);
await service.SetAsync("SupportEmail", "help@example.com", "setup");
var setting = await context.PlatformSettings.SingleAsync();
Assert.Equal("SupportEmail", setting.Key);
Assert.Equal("help@example.com", setting.Value);
Assert.Equal("setup", setting.UpdatedBy);
}
[Fact]
public async Task GetAllAsync_OrdersByGroupThenKey()
{
await using var context = CreateContext();
context.PlatformSettings.AddRange(
new PlatformSetting { Key = "Zeta", GroupName = "Billing", Value = "1" },
new PlatformSetting { Key = "Alpha", GroupName = "Billing", Value = "2" },
new PlatformSetting { Key = "Bravo", GroupName = "Alerts", Value = "3" });
await context.SaveChangesAsync();
var service = new PlatformSettingsService(context);
var settings = await service.GetAllAsync();
Assert.Equal(new[] { "Bravo", "Alpha", "Zeta" }, settings.Select(s => s.Key).ToArray());
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}
@@ -106,6 +106,435 @@ public class PricingCalculationServiceTests
Assert.Equal(246m, result.TotalPrice);
}
[Fact]
public async Task CalculateCoatPriceAsync_InventoryPowder_UsesCalculatedUsageOnly()
{
var unitOfWork = CreateUnitOfWorkMock(
CreateOperatingCosts(),
inventoryItem: new InventoryItem
{
Id = 12,
CompanyId = 1,
Name = "Gloss Black",
UnitCost = 12m
});
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var coat = new CreateQuoteItemCoatDto
{
CoatName = "Gloss Black",
InventoryItemId = 12,
CoverageSqFtPerLb = 24m,
TransferEfficiency = 50m
};
var result = await service.CalculateCoatPriceAsync(
coat,
itemSurfaceAreaSqFt: 10m,
quantity: 2m,
coatIndex: 0,
estimatedMinutesBase: 30,
companyId: 1);
Assert.Equal(20m, result.CoatMaterialCost, 2);
Assert.Equal(60m, result.CoatLaborCost);
Assert.Equal(80m, result.CoatTotalCost, 2);
}
[Fact]
public async Task CalculateCoatPriceAsync_MetricTenant_ConvertsSurfaceAreaBeforePricing()
{
var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts());
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(true);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var coat = new CreateQuoteItemCoatDto
{
CoatName = "Metric Blue",
PowderCostPerLb = 5m,
CoverageSqFtPerLb = 10m,
TransferEfficiency = 100m
};
var result = await service.CalculateCoatPriceAsync(
coat,
itemSurfaceAreaSqFt: 1m,
quantity: 1m,
coatIndex: 0,
estimatedMinutesBase: 0,
companyId: 1);
Assert.Equal(5.38m, result.CoatMaterialCost);
Assert.Equal(0m, result.CoatLaborCost);
Assert.Equal(5.38m, result.CoatTotalCost);
}
[Fact]
public async Task CalculateCoatPriceAsync_AdditionalCoatWithNoExtraLayerCharge_SkipsLabor()
{
var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts());
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var coat = new CreateQuoteItemCoatDto
{
CoatName = "Clear Coat",
PowderCostPerLb = 4m,
PowderToOrder = 1m,
CoverageSqFtPerLb = 20m,
TransferEfficiency = 100m,
NoExtraLayerCharge = true
};
var result = await service.CalculateCoatPriceAsync(
coat,
itemSurfaceAreaSqFt: 0m,
quantity: 2m,
coatIndex: 1,
estimatedMinutesBase: 45,
companyId: 1);
Assert.Equal(4m, result.CoatMaterialCost);
Assert.Equal(0m, result.CoatLaborCost);
Assert.Equal(4m, result.CoatTotalCost);
}
[Fact]
public async Task CalculateCoatPriceAsync_WhenOperatingCostsMissing_ReturnsZeros()
{
var unitOfWork = CreateUnitOfWorkMock(costs: null);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var result = await service.CalculateCoatPriceAsync(
new CreateQuoteItemCoatDto { CoatName = "Unpriced" },
itemSurfaceAreaSqFt: 10m,
quantity: 1m,
coatIndex: 0,
estimatedMinutesBase: 30,
companyId: 1);
Assert.Equal(0m, result.CoatMaterialCost);
Assert.Equal(0m, result.CoatLaborCost);
Assert.Equal(0m, result.CoatTotalCost);
}
[Fact]
public async Task CalculateQuoteItemPriceAsync_CatalogItem_UsesPowderCostOverrideAsBasePrice()
{
var unitOfWork = CreateUnitOfWorkMock(
CreateOperatingCosts(),
catalogItem: new CatalogItem
{
Id = 50,
CompanyId = 1,
Name = "Wheel",
DefaultPrice = 10m
});
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Override catalog item",
CatalogItemId = 50,
PowderCostOverride = 77m,
Quantity = 3m
};
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(0m, result.MaterialCost);
Assert.Equal(0m, result.LaborCost);
Assert.Equal(77m, result.UnitPrice);
Assert.Equal(231m, result.TotalPrice);
}
[Fact]
public async Task CalculateQuoteItemPriceAsync_CatalogItem_AddsPrepCostAndCustomPowder()
{
var unitOfWork = CreateUnitOfWorkMock(
CreateOperatingCosts(),
catalogItem: new CatalogItem
{
Id = 51,
CompanyId = 1,
Name = "Bracket",
DefaultPrice = 50m
});
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Bracket with prep",
CatalogItemId = 51,
Quantity = 2m,
IncludePrepCost = true,
PrepServices = new List<CreateQuoteItemPrepServiceDto>
{
new() { PrepServiceId = 1, EstimatedMinutes = 30 }
},
Coats = new List<CreateQuoteItemCoatDto>
{
new()
{
CoatName = "Custom Green",
PowderCostPerLb = 5m,
PowderToOrder = 2m
}
}
};
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(10m, result.MaterialCost);
Assert.Equal(30m, result.LaborCost);
Assert.Equal(0m, result.EquipmentCost);
Assert.Equal(70m, result.UnitPrice);
Assert.Equal(140m, result.TotalPrice);
}
[Fact]
public async Task CalculateQuoteItemPriceAsync_MarginMode_AppliesAdditionalCoatAndComplexity()
{
var costs = CreateOperatingCosts();
costs.PricingMode = PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost;
costs.TargetMarginPercent = 50m;
costs.CoatingBoothCostPerHour = 0m;
costs.ComplexityModeratePercent = 5m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Complex fabricated part",
Quantity = 1m,
SurfaceAreaSqFt = 10m,
EstimatedMinutes = 60,
Complexity = "Moderate",
Coats = new List<CreateQuoteItemCoatDto>
{
new()
{
CoatName = "Base",
PowderCostPerLb = 10m,
CoverageSqFtPerLb = 10m,
TransferEfficiency = 100m
},
new()
{
CoatName = "Top",
PowderCostPerLb = 10m,
CoverageSqFtPerLb = 10m,
TransferEfficiency = 100m
}
}
};
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(10.5m, result.MaterialCost);
Assert.Equal(60m, result.LaborCost);
Assert.Equal(0m, result.EquipmentCost);
Assert.Equal(222.075m, result.ItemSubtotal);
Assert.Equal(222.075m, result.TotalPrice);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_MixedAiAndManualItems_ScalesOvenCostBySurfaceAreaAndUsesManualTax()
{
var costs = CreateOperatingCosts();
costs.OvenOperatingCostPerHour = 30m;
costs.DefaultOvenCycleMinutes = 60;
costs.ShopSuppliesRate = 0m;
costs.TaxPercent = 5m;
costs.MonthlyRent = 0m;
costs.MonthlyUtilities = 0m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
new()
{
Description = "AI estimate",
IsAiItem = true,
ManualUnitPrice = 200m,
Quantity = 1m,
SurfaceAreaSqFt = 50m
},
new()
{
Description = "Labor item",
IsLaborItem = true,
Quantity = 1m,
SurfaceAreaSqFt = 50m,
EstimatedMinutes = 60
}
};
var result = await service.CalculateQuoteTotalsAsync(
items,
companyId: 1,
manualTaxPercent: 8m);
Assert.Equal(260m, result.ItemsSubtotal);
Assert.Equal(15m, result.OvenBatchCost);
Assert.Equal(275m, result.SubtotalBeforeDiscount);
Assert.Equal(8m, result.TaxPercent);
Assert.Equal(22m, result.TaxAmount);
Assert.Equal(297m, result.Total);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_ZeroSurfaceAreaFallback_UsesItemCountForOvenFraction()
{
var costs = CreateOperatingCosts();
costs.OvenOperatingCostPerHour = 20m;
costs.DefaultOvenCycleMinutes = 60;
costs.ShopSuppliesRate = 0m;
costs.TaxPercent = 0m;
costs.MonthlyRent = 0m;
costs.MonthlyUtilities = 0m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
new()
{
Description = "AI item",
IsAiItem = true,
ManualUnitPrice = 100m,
Quantity = 1m
},
new()
{
Description = "Shelf item",
IsSalesItem = true,
ManualUnitPrice = 40m,
Quantity = 1m
}
};
var result = await service.CalculateQuoteTotalsAsync(items, companyId: 1);
Assert.Equal(140m, result.ItemsSubtotal);
Assert.Equal(10m, result.OvenBatchCost);
Assert.Equal(150m, result.Total);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_FixedRushAndFacilityOverhead_AreAppliedBeforeTotal()
{
var costs = CreateOperatingCosts();
costs.OvenOperatingCostPerHour = 0m;
costs.MonthlyRent = 1600m;
costs.MonthlyUtilities = 0m;
costs.MonthlyBillableHours = 160;
costs.ShopSuppliesRate = 10m;
costs.RushChargeType = "FixedAmount";
costs.RushChargeFixedAmount = 25m;
costs.TaxPercent = 0m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
new()
{
Description = "Labor item",
IsLaborItem = true,
Quantity = 2m,
EstimatedMinutes = 60
}
};
var result = await service.CalculateQuoteTotalsAsync(
items,
companyId: 1,
isRushJob: true);
Assert.Equal(120m, result.ItemsSubtotal);
Assert.Equal(10m, result.FacilityOverheadRatePerHour);
Assert.Equal(20m, result.FacilityOverheadCost);
Assert.Equal(12m, result.ShopSuppliesAmount);
Assert.Equal(152m, result.SubtotalBeforeDiscount);
Assert.Equal(25m, result.RushFee);
Assert.Equal(177m, result.Total);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_AppliesTierDiscount_QuoteDiscount_RushFee_AndTax()
{
@@ -174,24 +603,27 @@ public class PricingCalculationServiceTests
Assert.Equal(243.18m, result.Total);
}
private static Mock<IUnitOfWork> CreateUnitOfWorkMock(CompanyOperatingCosts costs)
private static Mock<IUnitOfWork> CreateUnitOfWorkMock(
CompanyOperatingCosts? costs,
InventoryItem? inventoryItem = null,
CatalogItem? catalogItem = null)
{
var unitOfWork = new Mock<IUnitOfWork>();
var companyOperatingCostsRepo = new Mock<IRepository<CompanyOperatingCosts>>();
companyOperatingCostsRepo
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<CompanyOperatingCosts, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CompanyOperatingCosts, object>>[]>()))
.ReturnsAsync(new[] { costs });
.ReturnsAsync(costs != null ? new[] { costs } : Array.Empty<CompanyOperatingCosts>());
var inventoryRepo = new Mock<IRepository<InventoryItem>>();
inventoryRepo
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<InventoryItem, object>>[]>()))
.ReturnsAsync((InventoryItem?)null);
.ReturnsAsync(inventoryItem);
var catalogRepo = new Mock<IRepository<CatalogItem>>();
catalogRepo
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CatalogItem, object>>[]>()))
.ReturnsAsync((CatalogItem?)null);
.ReturnsAsync(catalogItem);
var customerRepo = new Mock<IRepository<Customer>>();
customerRepo
@@ -0,0 +1,449 @@
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Web.Controllers;
using PowderCoating.Web.Hubs;
using PowderCoating.Web.ViewModels;
namespace PowderCoating.UnitTests;
public class QuoteApprovalControllerTests
{
[Fact]
public async Task ShowApprovalPage_WhenTokenExpired_ReturnsTokenExpiredView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "expired-token", expiresAt: DateTime.UtcNow.AddMinutes(-1)));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.ShowApprovalPage("expired-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("TokenExpired", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("expired-token", model.Token);
}
[Fact]
public async Task ShowApprovalPage_WhenQuoteAlreadyInTerminalStatus_ReturnsAlreadyActedView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "approved-token", statusId: 2, declineReason: "Old decline"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.ShowApprovalPage("approved-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("AlreadyActed", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Approved", model.CurrentStatus);
Assert.Equal("Old decline", model.DeclineReason);
}
[Fact]
public async Task Approve_WhenQuoteIsProspect_ReturnsConfirmDetailsView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "prospect-token"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("prospect-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ConfirmDetails", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.True(model.IsProspect);
}
[Fact]
public async Task SubmitDetails_WhenRequiredFieldsMissing_ReturnsConfirmDetailsWithError()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "missing-details"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.SubmitDetails(
"missing-details",
contactName: " ",
email: " prospect@example.com ",
phone: null,
companyName: " Prospect Co ",
address: " 123 Main ",
city: " Akron ",
state: " OH ",
zipCode: " 44301 ");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ConfirmDetails", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Please enter your name and at least one contact method (email or phone).", model.DeclineError);
Assert.Equal(" prospect@example.com ", model.ProspectEmail);
Assert.Equal(" Prospect Co ", model.ProspectCompanyName);
}
[Fact]
public async Task SubmitDetails_WhenValidProspect_ApprovesQuoteAndTrimsFields()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "prospect-approve"));
await context.SaveChangesAsync();
var notifications = new Mock<INotificationService>();
notifications
.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), true, null))
.Returns(Task.CompletedTask);
var inApp = new Mock<IInAppNotificationService>();
inApp.Setup(x => x.CreateAsync(1, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int?>()))
.Returns(Task.CompletedTask);
var clientProxy = new Mock<IClientProxy>();
clientProxy
.Setup(x => x.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var controller = CreateController(
context,
notifications: notifications,
inApp: inApp,
clientProxy: clientProxy);
var result = await controller.SubmitDetails(
"prospect-approve",
contactName: " Pat Prospect ",
email: " prospect@example.com ",
phone: " 555-0100 ",
companyName: " Prospect Co ",
address: " 123 Main ",
city: " Akron ",
state: " OH ",
zipCode: " 44301 ");
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/prospect-approve/confirmation?action=approved", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(2, quote.QuoteStatusId);
Assert.Equal("Pat Prospect", quote.ProspectContactName);
Assert.Equal("prospect@example.com", quote.ProspectEmail);
Assert.Equal("555-0100", quote.ProspectPhone);
Assert.Equal("Prospect Co", quote.ProspectCompanyName);
Assert.NotNull(quote.ApprovalTokenUsedAt);
Assert.Single(await context.QuoteChangeHistories.IgnoreQueryFilters().ToListAsync());
notifications.Verify(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), true, null), Times.Once);
inApp.Verify(x => x.CreateAsync(1, "Quote Approved", It.IsAny<string>(), "QuoteApproved", "/Quotes/Details/1", 1, null, null), Times.Once);
clientProxy.Verify(x => x.SendCoreAsync("QuoteActedByCustomer", It.IsAny<object[]>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Approve_WhenCustomerQuoteRequiresDeposit_GeneratesDepositLinkAndClearsPriorDecline()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1, stripeStatus: StripeConnectStatus.Active);
context.Customers.Add(new Customer
{
Id = 10,
CompanyId = 1,
CompanyName = "Acme Customer"
});
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "deposit-token",
requiresDeposit: true,
depositPercent: 50m,
declineReason: "Need more time"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("deposit-token");
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/deposit-token/confirmation?action=approved", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(2, quote.QuoteStatusId);
Assert.Null(quote.DeclineReason);
Assert.NotNull(quote.DepositPaymentLinkToken);
Assert.True(quote.DepositPaymentLinkExpiresAt > DateTime.UtcNow.AddDays(6));
var history = await context.QuoteChangeHistories.IgnoreQueryFilters().SingleAsync();
Assert.Contains("previously declined", history.ChangeDescription);
}
[Fact]
public async Task Approve_WhenTokenAlreadyUsed_ReturnsAlreadyActedView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "used-token",
approvalUsedAt: DateTime.UtcNow.AddMinutes(-5)));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("used-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("AlreadyActed", view.ViewName);
}
[Fact]
public async Task Decline_WhenReasonBlank_ReturnsApprovalPageWithError()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "blank-decline"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Decline("blank-decline", " ");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ApprovalPage", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Please enter a reason for declining.", model.DeclineError);
}
[Fact]
public async Task Decline_UsesRejectedStatusCodeFallbackAndTruncatesStoredReason()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1, useRejectedFlag: false);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "decline-token"));
await context.SaveChangesAsync();
var notifications = new Mock<INotificationService>();
notifications
.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), false, It.IsAny<string>()))
.Returns(Task.CompletedTask);
var reason = $" {new string('x', 1005)} ";
var controller = CreateController(
context,
notifications: notifications,
remoteIpAddress: IPAddress.Parse("203.0.113.9"));
var result = await controller.Decline("decline-token", reason);
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/decline-token/confirmation?action=declined", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(3, quote.QuoteStatusId);
Assert.Equal(1000, quote.DeclineReason!.Length);
Assert.Equal("203.0.113.9", quote.DeclinedByIp);
Assert.NotNull(quote.ApprovalTokenUsedAt);
Assert.Single(await context.QuoteChangeHistories.IgnoreQueryFilters().ToListAsync());
notifications.Verify(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), false, It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task Confirmation_HidesExpiredDepositLink()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "confirm-token",
requiresDeposit: true,
depositPercent: 25m,
depositLinkToken: "expired-link",
depositLinkExpiresAt: DateTime.UtcNow.AddMinutes(-2),
total: 120m));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Confirmation("confirm-token", "APPROVED");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Confirmation", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Null(model.DepositPaymentLinkToken);
Assert.Equal(30m, model.DepositAmount);
Assert.Equal("approved", controller.ViewBag.Action);
}
private static QuoteApprovalController CreateController(
ApplicationDbContext context,
Mock<INotificationService>? notifications = null,
Mock<IInAppNotificationService>? inApp = null,
Mock<IClientProxy>? clientProxy = null,
IPAddress? remoteIpAddress = null)
{
notifications ??= new Mock<INotificationService>();
notifications.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), It.IsAny<bool>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
inApp ??= new Mock<IInAppNotificationService>();
inApp.Setup(x => x.CreateAsync(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int?>()))
.Returns(Task.CompletedTask);
clientProxy ??= new Mock<IClientProxy>();
clientProxy.Setup(x => x.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var hubClients = new Mock<IHubClients>();
hubClients.Setup(x => x.Group(It.IsAny<string>())).Returns(clientProxy.Object);
var hubContext = new Mock<IHubContext<NotificationHub>>();
hubContext.SetupGet(x => x.Clients).Returns(hubClients.Object);
var controller = new QuoteApprovalController(
context,
notifications.Object,
inApp.Object,
Mock.Of<IStripeConnectService>(),
Mock.Of<ILogger<QuoteApprovalController>>(),
new ConfigurationBuilder().Build(),
hubContext.Object);
var httpContext = new DefaultHttpContext();
if (remoteIpAddress != null)
{
httpContext.Connection.RemoteIpAddress = remoteIpAddress;
}
controller.ControllerContext = new ControllerContext
{
HttpContext = httpContext
};
return controller;
}
private static void SeedCompanyAndStatuses(
ApplicationDbContext context,
int companyId,
StripeConnectStatus stripeStatus = StripeConnectStatus.NotConnected,
bool useRejectedFlag = true)
{
context.Companies.Add(new Company
{
Id = companyId,
CompanyId = companyId,
CompanyName = $"Company {companyId}",
Phone = "555-0100",
PrimaryContactName = "Owner",
PrimaryContactEmail = $"owner{companyId}@example.com",
StripeConnectStatus = stripeStatus
});
context.CompanyPreferences.Add(new CompanyPreferences
{
Id = companyId,
CompanyId = companyId,
EmailFromAddress = $"quotes{companyId}@example.com"
});
context.QuoteStatusLookups.AddRange(
new QuoteStatusLookup
{
Id = 1,
CompanyId = companyId,
StatusCode = "PENDING",
DisplayName = "Pending",
DisplayOrder = 1
},
new QuoteStatusLookup
{
Id = 2,
CompanyId = companyId,
StatusCode = "APPROVED",
DisplayName = "Approved",
DisplayOrder = 2,
IsApprovedStatus = true
},
new QuoteStatusLookup
{
Id = 3,
CompanyId = companyId,
StatusCode = "REJECTED",
DisplayName = "Rejected",
DisplayOrder = 3,
IsRejectedStatus = useRejectedFlag
},
new QuoteStatusLookup
{
Id = 4,
CompanyId = companyId,
StatusCode = "CONVERTED",
DisplayName = "Converted",
DisplayOrder = 4,
IsConvertedStatus = true
});
}
private static Quote CreateQuote(
int id,
int? customerId,
string token,
int statusId = 1,
DateTime? expiresAt = null,
DateTime? approvalUsedAt = null,
bool requiresDeposit = false,
decimal depositPercent = 0m,
string? declineReason = null,
string? depositLinkToken = null,
DateTime? depositLinkExpiresAt = null,
decimal total = 100m)
{
return new Quote
{
Id = id,
CompanyId = 1,
QuoteNumber = $"Q-{id:000}",
CustomerId = customerId,
QuoteStatusId = statusId,
ApprovalToken = token,
ApprovalTokenExpiresAt = expiresAt ?? DateTime.UtcNow.AddDays(2),
ApprovalTokenUsedAt = approvalUsedAt,
RequiresDeposit = requiresDeposit,
DepositPercent = depositPercent,
DeclineReason = declineReason,
DepositPaymentLinkToken = depositLinkToken,
DepositPaymentLinkExpiresAt = depositLinkExpiresAt,
Total = total,
SubTotal = total,
QuoteItems = []
};
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}
@@ -0,0 +1,239 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
namespace PowderCoating.UnitTests;
public class QuotePhotoServiceTests
{
[Fact]
public async Task SaveTempPhotoAsync_ReturnsError_WhenFileMissing()
{
var service = CreateService();
var result = await service.SaveTempPhotoAsync(null!, companyId: 1);
Assert.False(result.Success);
Assert.Equal("No file provided.", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_ReturnsError_WhenFileTooLarge()
{
var service = CreateService();
var file = CreateFormFile("huge.jpg", 10 * 1024 * 1024 + 1);
var result = await service.SaveTempPhotoAsync(file, companyId: 1);
Assert.False(result.Success);
Assert.Equal("File exceeds the 10 MB limit.", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_ReturnsError_WhenExtensionNotAllowed()
{
var service = CreateService();
var file = CreateFormFile("photo.bmp");
var result = await service.SaveTempPhotoAsync(file, companyId: 1);
Assert.False(result.Success);
Assert.Equal("File type '.bmp' is not allowed.", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_ReturnsBlobError_WhenUploadFails()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((false, "blob upload failed"));
var service = CreateService(blobService: blobService);
var file = CreateFormFile("photo.png");
var result = await service.SaveTempPhotoAsync(file, companyId: 1);
Assert.False(result.Success);
Assert.Equal("blob upload failed", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_UploadsToTempPath_WhenValid()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/jpeg"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService: blobService);
var file = CreateFormFile("photo.jpg");
var result = await service.SaveTempPhotoAsync(file, companyId: 5);
Assert.True(result.Success);
Assert.False(string.IsNullOrWhiteSpace(result.TempId));
Assert.StartsWith($"temp/{result.TempId}/", result.FilePath);
Assert.EndsWith(".jpg", result.FilePath);
}
[Fact]
public async Task PromoteTempPhotoAsync_ReturnsError_WhenTempPhotoMissing()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(Array.Empty<string>());
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.False(result.Success);
Assert.Equal("Temp photo not found.", result.ErrorMessage);
}
[Fact]
public async Task PromoteTempPhotoAsync_ReturnsError_WhenTempDownloadFails()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/original.png" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.png"))
.ReturnsAsync((false, Array.Empty<byte>(), string.Empty, "download failed"));
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.False(result.Success);
Assert.Equal("Failed to read temp photo.", result.ErrorMessage);
}
[Fact]
public async Task PromoteTempPhotoAsync_ReturnsError_WhenPermanentUploadFails()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/original.webp" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.webp"))
.ReturnsAsync((true, new byte[] { 1, 2, 3 }, "image/webp", string.Empty));
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/webp"))
.ReturnsAsync((false, "upload failed"));
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.False(result.Success);
Assert.Equal("Failed to save permanent photo.", result.ErrorMessage);
}
[Fact]
public async Task PromoteTempPhotoAsync_PromotesAndDeletesTempBlob_WhenSuccessful()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/original.png" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.png"))
.ReturnsAsync((true, new byte[] { 1, 2, 3 }, "image/png", string.Empty));
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((true, string.Empty));
blobService
.Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/original.png"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.True(result.Success);
Assert.StartsWith("3/quote-photos/10/", result.FilePath);
Assert.EndsWith(".png", result.FilePath);
blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/original.png"), Times.Once);
}
[Fact]
public async Task ReadTempPhotosAsync_ReturnsOnlySuccessfulDownloads()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/one.jpg", "temp/temp123/two.jpg" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/one.jpg"))
.ReturnsAsync((true, new byte[] { 1 }, "image/jpeg", string.Empty));
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/two.jpg"))
.ReturnsAsync((false, Array.Empty<byte>(), string.Empty, "failed"));
var service = CreateService(blobService: blobService);
var result = await service.ReadTempPhotosAsync("temp123");
Assert.Single(result);
Assert.Equal("one.jpg", result[0].FileName);
Assert.Equal("image/jpeg", result[0].ContentType);
}
[Fact]
public async Task CleanupTempAsync_ContinuesDeleting_WhenOneDeleteThrows()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/one.jpg", "temp/temp123/two.jpg" });
blobService
.Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/one.jpg"))
.ThrowsAsync(new InvalidOperationException("boom"));
blobService
.Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/two.jpg"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService: blobService);
await service.CleanupTempAsync("temp123");
blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/one.jpg"), Times.Once);
blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/two.jpg"), Times.Once);
}
private static QuotePhotoService CreateService(Mock<IAzureBlobStorageService>? blobService = null)
{
var settings = Options.Create(new StorageSettings
{
Containers = new StorageContainers
{
QuoteImages = "quoteimages"
}
});
return new QuotePhotoService(
(blobService ?? new Mock<IAzureBlobStorageService>()).Object,
settings,
Mock.Of<ILogger<QuotePhotoService>>());
}
private static IFormFile CreateFormFile(string fileName, long? lengthOverride = null)
{
var dataLength = lengthOverride.HasValue
? (int)Math.Min(lengthOverride.Value, 1024)
: 16;
var bytes = Enumerable.Repeat((byte)65, dataLength).ToArray();
var stream = new MemoryStream(bytes);
return new FormFile(stream, 0, lengthOverride ?? bytes.Length, "file", fileName);
}
}
@@ -1,13 +1,18 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.Registration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Shared.Constants;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using System.Text.Json;
using PowderCoating.Web.Controllers;
using Xunit;
@@ -95,18 +100,315 @@ public class RegistrationControllerTests
Assert.True((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
}
[Fact]
public async Task Create_WhenEmailAlreadyExists_ReturnsIndexWithModelError()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
await context.SaveChangesAsync();
var existingUser = new ApplicationUser
{
Id = "existing",
Email = "owner@example.com",
UserName = "owner@example.com",
FirstName = "Existing",
LastName = "User"
};
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.FindByEmailAsync("owner@example.com")).ReturnsAsync(existingUser);
var controller = CreateController(context, userManager: userManager);
var model = new RegisterCompanyDto
{
CompanyName = "Dup Co",
CompanyPhone = "555-0100",
FirstName = "Pat",
LastName = "Owner",
Email = "owner@example.com",
Plan = 1
};
var result = await controller.Create(model);
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Index", view.ViewName);
Assert.False(controller.ModelState.IsValid);
Assert.Contains(controller.ModelState["Email"]!.Errors, e => e.ErrorMessage.Contains("already exists"));
}
[Fact]
public async Task Create_WhenRegistrationIsClosed_ReturnsIndexWithTempDataError()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
context.Companies.Add(new Company
{
Id = 1,
CompanyId = 1,
CompanyName = "Existing Company",
CompanyCode = "EXC",
PrimaryContactName = "Owner",
PrimaryContactEmail = "existing@example.com",
IsActive = true
});
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings
.Setup(x => x.GetAsync(PlatformSettingKeys.MaxTenants))
.ReturnsAsync("1");
platformSettings
.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.MaxTenants)))
.ReturnsAsync((string?)null);
var controller = CreateController(context, platformSettings: platformSettings);
var model = new RegisterCompanyDto
{
CompanyName = "New Co",
CompanyPhone = "555-0100",
FirstName = "Pat",
LastName = "Owner",
Email = "owner@example.com",
Plan = 1
};
var result = await controller.Create(model);
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Index", view.ViewName);
Assert.Equal("Registration is currently closed. Please contact us for more information.", controller.TempData["Error"]);
Assert.False((bool)controller.ViewBag.RegistrationOpen);
}
[Fact]
public async Task Create_WhenTrialsDisabledAndStripeCheckoutStarts_RedirectsAndPersistsPendingSession()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings.Setup(x => x.GetAsync(PlatformSettingKeys.TrialsEnabled)).ReturnsAsync("false");
platformSettings.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.TrialsEnabled))).ReturnsAsync((string?)null);
var stripeService = new Mock<IStripeService>();
stripeService
.Setup(x => x.CreateRegistrationCheckoutSessionAsync(
1, false, "paid@example.com", "Paid Co", It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync("https://checkout.example/session");
var controller = CreateController(
context,
stripeService: stripeService,
platformSettings: platformSettings);
var model = new RegisterCompanyDto
{
CompanyName = "Paid Co",
CompanyPhone = "555-0100",
FirstName = "Pat",
LastName = "Owner",
Email = "paid@example.com",
Plan = 1
};
var result = await controller.Create(model);
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("https://checkout.example/session", redirect.Url);
var pending = await context.PendingRegistrationSessions.SingleAsync();
Assert.Equal("Paid Co", pending.CompanyName);
Assert.Equal("paid@example.com", pending.Email);
Assert.False(pending.IsCompleted);
}
[Fact]
public async Task Create_WhenStripeConfigFails_DoesNotPersistPendingSession()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings.Setup(x => x.GetAsync(PlatformSettingKeys.TrialsEnabled)).ReturnsAsync("false");
platformSettings.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.TrialsEnabled))).ReturnsAsync((string?)null);
var stripeService = new Mock<IStripeService>();
stripeService
.Setup(x => x.CreateRegistrationCheckoutSessionAsync(
It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException("Stripe prices are not configured."));
var controller = CreateController(
context,
stripeService: stripeService,
platformSettings: platformSettings);
var model = new RegisterCompanyDto
{
CompanyName = "Paid Co",
CompanyPhone = "555-0100",
FirstName = "Pat",
LastName = "Owner",
Email = "paid@example.com",
Plan = 1
};
var result = await controller.Create(model);
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Index", view.ViewName);
Assert.Empty(context.PendingRegistrationSessions);
Assert.Contains(controller.ModelState[string.Empty]!.Errors, e => e.ErrorMessage.Contains("Stripe prices are not configured."));
}
[Fact]
public async Task PaymentSuccess_WhenRegistrationClosedAfterPayment_ReleasesPendingSessionWithoutCreatingCompany()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
context.Companies.Add(new Company
{
Id = 1,
CompanyId = 1,
CompanyName = "At Capacity",
CompanyCode = "CAP",
PrimaryContactName = "Owner",
PrimaryContactEmail = "capacity@example.com",
IsActive = true
});
context.PendingRegistrationSessions.Add(CreatePendingSession("token-closed", "closed@example.com"));
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings
.Setup(x => x.GetAsync(PlatformSettingKeys.MaxTenants))
.ReturnsAsync("1");
platformSettings
.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.MaxTenants)))
.ReturnsAsync((string?)null);
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.FindByEmailAsync("closed@example.com")).ReturnsAsync((ApplicationUser?)null);
var stripeService = new Mock<IStripeService>();
stripeService.Setup(x => x.IsRegistrationCheckoutPaidAsync("sess_paid")).ReturnsAsync(true);
var controller = CreateController(
context,
userManager: userManager,
stripeService: stripeService,
platformSettings: platformSettings);
var result = await controller.PaymentSuccess("sess_paid", "token-closed");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Equal("Registration is currently closed. Your payment has been received but no account was created. Please contact support.", controller.TempData["Error"]);
Assert.False((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
Assert.Single(context.Companies);
}
[Fact]
public async Task PaymentSuccess_WhenSessionIdMissing_RedirectsToIndex()
{
await using var context = CreateContext();
var controller = CreateController(context);
var result = await controller.PaymentSuccess(null, "token");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
}
[Fact]
public async Task PaymentSuccess_WhenRegistrationTokenMissing_SetsExpiredError()
{
await using var context = CreateContext();
var controller = CreateController(context);
var result = await controller.PaymentSuccess("sess_123", null);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Equal("Your registration session has expired. Please fill in your details again.", controller.TempData["Error"]);
}
[Fact]
public async Task PaymentSuccess_WhenPendingSessionMissing_SetsNotFoundError()
{
await using var context = CreateContext();
var controller = CreateController(context);
var result = await controller.PaymentSuccess("sess_123", "missing-token");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Equal("Your registration session was not found. Please fill in your details again.", controller.TempData["Error"]);
}
[Fact]
public async Task PaymentSuccess_WhenSessionAlreadyCompletedButUserMissing_ShowsSupportError()
{
await using var context = CreateContext();
context.PendingRegistrationSessions.Add(CreatePendingSession("token-missing-user", "retry@example.com", isCompleted: true));
await context.SaveChangesAsync();
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.FindByEmailAsync("retry@example.com")).ReturnsAsync((ApplicationUser?)null);
var controller = CreateController(context, userManager: userManager);
var result = await controller.PaymentSuccess("sess_done", "token-missing-user");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Contains("couldn't finish signing you in", controller.TempData["Error"]?.ToString());
}
[Fact]
public async Task PaymentCancelled_WhenPendingSessionExists_PrefillsTempDataAndDeletesSession()
{
await using var context = CreateContext();
context.PendingRegistrationSessions.Add(CreatePendingSession("token-cancel", "cancel@example.com"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.PaymentCancelled("token-cancel");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
var json = Assert.IsType<string>(controller.TempData["PendingRegistrationJson"]);
var model = JsonSerializer.Deserialize<RegisterCompanyDto>(json);
Assert.NotNull(model);
Assert.Equal("Retry Co", model!.CompanyName);
Assert.Equal("cancel@example.com", model.Email);
Assert.Empty(context.PendingRegistrationSessions);
}
private static RegistrationController CreateController(
ApplicationDbContext context,
Mock<UserManager<ApplicationUser>>? userManager = null,
SignInManager<ApplicationUser>? signInManager = null,
Mock<IStripeService>? stripeService = null)
Mock<IStripeService>? stripeService = null,
Mock<IPlatformSettingsService>? platformSettings = null)
{
var unitOfWork = new UnitOfWork(context);
var userManagerMock = userManager ?? CreateUserManagerMock();
var signInManagerInstance = signInManager ?? CreateSignInManagerMock(userManagerMock.Object).Object;
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings.Setup(x => x.GetAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
var platformSettingsMock = platformSettings ?? new Mock<IPlatformSettingsService>();
if (platformSettings is null)
{
platformSettingsMock.Setup(x => x.GetAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
}
var controller = new RegistrationController(
unitOfWork,
@@ -116,7 +418,7 @@ public class RegistrationControllerTests
Mock.Of<ISeedDataService>(),
Mock.Of<IAdminNotificationService>(),
Mock.Of<IInAppNotificationService>(),
platformSettings.Object,
platformSettingsMock.Object,
(stripeService ?? new Mock<IStripeService>()).Object,
Mock.Of<IEmailService>(),
Mock.Of<ILogger<RegistrationController>>());
@@ -126,6 +428,11 @@ public class RegistrationControllerTests
{
HttpContext = httpContext
};
var urlHelper = new Mock<IUrlHelper>();
urlHelper
.Setup(x => x.Action(It.IsAny<UrlActionContext>()))
.Returns<UrlActionContext>(ctx => $"https://example.test/{ctx.Action}");
controller.Url = urlHelper.Object;
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
return controller;
@@ -172,6 +479,19 @@ public class RegistrationControllerTests
return new ApplicationDbContext(options);
}
private static void SeedPlanConfig(ApplicationDbContext context, int plan)
{
context.SubscriptionPlanConfigs.Add(new SubscriptionPlanConfig
{
Id = plan,
CompanyId = 0,
Plan = plan,
DisplayName = $"Plan {plan}",
SortOrder = plan,
IsActive = true
});
}
private static PendingRegistrationSession CreatePendingSession(string token, string email, bool isCompleted = false)
{
return new PendingRegistrationSession
@@ -0,0 +1,107 @@
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.UnitTests;
public class ShopCapabilityCalculatorTests
{
[Fact]
public void GetBlastRateSqFtPerHour_WithOverride_ReturnsOverride()
{
var costs = new CompanyOperatingCosts
{
BlastRateSqFtPerHourOverride = 42.5m,
CompressorCfm = 150m,
BlastNozzleSize = 6,
BlastSetupType = BlastSetupType.PressurePot,
PrimaryBlastSubstrate = BlastSubstrateType.Paint
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(42.5m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_WithNoCompressorCfm_ReturnsZero()
{
var costs = new CompanyOperatingCosts
{
CompressorCfm = 0m
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(0m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_DerivesRateFromEquipmentInputs()
{
var costs = new CompanyOperatingCosts
{
CompressorCfm = 150m,
BlastNozzleSize = 6,
BlastSetupType = BlastSetupType.PressurePot,
PrimaryBlastSubstrate = BlastSubstrateType.Paint
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(58.5m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_ForNamedSetup_UsesSetupOverload()
{
var setup = new CompanyBlastSetup
{
Name = "Main Cabinet",
CompressorCfm = 7m,
BlastNozzleSize = 4,
SetupType = BlastSetupType.SiphonCabinet,
PrimarySubstrate = BlastSubstrateType.Mixed
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
Assert.Equal(1.7m, result);
}
[Theory]
[InlineData(CoatingGunType.Corona, 40)]
[InlineData(CoatingGunType.Tribo, 35)]
[InlineData(CoatingGunType.Both, 40)]
public void GetCoatingRateSqFtPerHour_ReturnsExpectedDefaultByGunType(CoatingGunType gunType, decimal expected)
{
var costs = new CompanyOperatingCosts
{
CoatingGunType = gunType
};
var result = ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(costs);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(ShopCapabilityTier.Garage, BlastSetupType.SiphonCabinet, 7, 4, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Small, BlastSetupType.PressurePot, 40, 5, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Medium, BlastSetupType.PressurePot, 80, 5, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Large, BlastSetupType.PressurePot, 150, 6, BlastSubstrateType.Mixed)]
public void TierDefaults_ReturnExpectedPresetValues(
ShopCapabilityTier tier,
BlastSetupType expectedSetup,
decimal expectedCfm,
int expectedNozzle,
BlastSubstrateType expectedSubstrate)
{
var defaults = ShopCapabilityCalculator.TierDefaults(tier);
Assert.Equal(expectedSetup, defaults.SetupType);
Assert.Equal(expectedCfm, defaults.Cfm);
Assert.Equal(expectedNozzle, defaults.NozzleSize);
Assert.Equal(expectedSubstrate, defaults.Substrate);
}
}
@@ -0,0 +1,213 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
namespace PowderCoating.UnitTests;
public class StorageMigrationServiceTests
{
[Fact]
public async Task MigrateFilesystemToAzureAsync_ReturnsError_WhenDirectoryMissing()
{
var service = CreateService();
var missingPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
var result = await service.MigrateFilesystemToAzureAsync(missingPath);
Assert.Equal(0, result.Failed);
Assert.Equal(0, result.Total);
Assert.Contains("Media directory not found", result.Errors.Single());
Assert.True(result.HasErrors);
}
[Fact]
public async Task MigrateFilesystemToAzureAsync_MarksUnknownPathsAsFailed()
{
var mediaRoot = CreateTempMediaRoot();
try
{
var unknownPath = Path.Combine(mediaRoot, "1", "misc");
Directory.CreateDirectory(unknownPath);
await File.WriteAllTextAsync(Path.Combine(unknownPath, "file.bin"), "abc");
var service = CreateService();
var result = await service.MigrateFilesystemToAzureAsync(mediaRoot);
Assert.Equal(1, result.Failed);
Assert.Contains(result.Errors, e => e.Contains("Unknown file type"));
Assert.Equal(MigrationFileStatus.Failed, result.Files.Single().Status);
}
finally
{
SafeDeleteDirectory(mediaRoot);
}
}
[Fact]
public async Task MigrateFilesystemToAzureAsync_SkipsExistingBlobs()
{
var mediaRoot = CreateTempMediaRoot();
try
{
var filePath = WriteMediaFile(mediaRoot, "1/profile-photos/user.jpg", "profile");
var relativePath = "1/profile-photos/user.jpg";
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ExistsAsync("profileimages", relativePath))
.ReturnsAsync(true);
var service = CreateService(blobService);
var result = await service.MigrateFilesystemToAzureAsync(mediaRoot);
Assert.Equal(1, result.Skipped);
Assert.Equal(0, result.Migrated);
Assert.Equal(MigrationFileStatus.Skipped, result.Files.Single().Status);
Assert.True(File.Exists(filePath));
}
finally
{
SafeDeleteDirectory(mediaRoot);
}
}
[Fact]
public async Task MigrateFilesystemToAzureAsync_RecordsUploadFailures()
{
var mediaRoot = CreateTempMediaRoot();
try
{
WriteMediaFile(mediaRoot, "1/job-photos/9/photo.png", "photo");
const string relativePath = "1/job-photos/9/photo.png";
var blobService = new Mock<IAzureBlobStorageService>();
blobService.Setup(x => x.ExistsAsync("jobimages", relativePath)).ReturnsAsync(false);
blobService
.Setup(x => x.UploadAsync("jobimages", relativePath, It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((false, "upload failed"));
var service = CreateService(blobService);
var result = await service.MigrateFilesystemToAzureAsync(mediaRoot);
Assert.Equal(1, result.Failed);
Assert.Contains(result.Errors, e => e.Contains("upload failed"));
Assert.Equal(MigrationFileStatus.Failed, result.Files.Single().Status);
}
finally
{
SafeDeleteDirectory(mediaRoot);
}
}
[Fact]
public async Task MigrateFilesystemToAzureAsync_DeletesLocalFileAfterSuccessfulMigration_WhenRequested()
{
var mediaRoot = CreateTempMediaRoot();
try
{
var fullPath = WriteMediaFile(mediaRoot, "1/company-logo.png", "logo");
const string relativePath = "1/company-logo.png";
var blobService = new Mock<IAzureBlobStorageService>();
blobService.Setup(x => x.ExistsAsync("companylogos", relativePath)).ReturnsAsync(false);
blobService
.Setup(x => x.UploadAsync("companylogos", relativePath, It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService);
var result = await service.MigrateFilesystemToAzureAsync(mediaRoot, deleteLocalAfterMigration: true);
Assert.Equal(1, result.Migrated);
Assert.Contains(result.Files, f => f.RelativePath == relativePath && f.Status == MigrationFileStatus.Migrated);
Assert.False(File.Exists(fullPath));
}
finally
{
SafeDeleteDirectory(mediaRoot);
}
}
[Fact]
public async Task MigrateFilesystemToAzureAsync_ContinuesAfterPerFileException()
{
var mediaRoot = CreateTempMediaRoot();
try
{
WriteMediaFile(mediaRoot, "1/profile-photos/user.jpg", "profile");
WriteMediaFile(mediaRoot, "1/equipment-manuals/manual.pdf", "manual");
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ExistsAsync("profileimages", "1/profile-photos/user.jpg"))
.ThrowsAsync(new InvalidOperationException("broken exists"));
blobService
.Setup(x => x.ExistsAsync("manuals", "1/equipment-manuals/manual.pdf"))
.ReturnsAsync(false);
blobService
.Setup(x => x.UploadAsync("manuals", "1/equipment-manuals/manual.pdf", It.IsAny<Stream>(), "application/pdf"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService);
var result = await service.MigrateFilesystemToAzureAsync(mediaRoot);
Assert.Equal(1, result.Failed);
Assert.Equal(1, result.Migrated);
Assert.Contains(result.Errors, e => e.Contains("broken exists"));
Assert.Equal(2, result.Total);
}
finally
{
SafeDeleteDirectory(mediaRoot);
}
}
private static StorageMigrationService CreateService(Mock<IAzureBlobStorageService>? blobService = null)
{
var settings = Options.Create(new StorageSettings
{
Containers = new StorageContainers
{
ProfileImages = "profileimages",
JobImages = "jobimages",
Manuals = "manuals",
CompanyLogos = "companylogos"
}
});
return new StorageMigrationService(
(blobService ?? new Mock<IAzureBlobStorageService>()).Object,
settings,
Mock.Of<ILogger<StorageMigrationService>>());
}
private static string CreateTempMediaRoot()
{
var path = Path.Combine(Path.GetTempPath(), "pca-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static string WriteMediaFile(string mediaRoot, string relativePath, string content)
{
var fullPath = Path.Combine(mediaRoot, relativePath.Replace("/", Path.DirectorySeparatorChar.ToString()));
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
File.WriteAllText(fullPath, content);
return fullPath;
}
private static void SafeDeleteDirectory(string path)
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
}
@@ -130,6 +130,191 @@ public class SubscriptionServiceTests
Assert.False(allowed);
}
[Fact]
public async Task GetStatusAsync_ReturnsGracePeriod_WhenSubscriptionRecentlyExpired()
{
await using var context = CreateContext();
context.Companies.Add(new Company
{
Id = 20,
CompanyId = 20,
CompanyName = "Grace Co",
PrimaryContactName = "Owner",
PrimaryContactEmail = "grace@example.com",
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-5),
IsActive = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var status = await service.GetStatusAsync(20);
Assert.Equal(SubscriptionStatus.GracePeriod, status);
}
[Fact]
public async Task GetStatusAsync_ReturnsExpired_WhenPastGraceWindow()
{
await using var context = CreateContext();
context.Companies.Add(new Company
{
Id = 21,
CompanyId = 21,
CompanyName = "Expired Co",
PrimaryContactName = "Owner",
PrimaryContactEmail = "expired@example.com",
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-15),
IsActive = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var status = await service.GetStatusAsync(21);
Assert.Equal(SubscriptionStatus.Expired, status);
}
[Fact]
public async Task GetStatusAsync_ReturnsActive_ForCompedCompanyEvenWhenExpired()
{
await using var context = CreateContext();
context.Companies.Add(new Company
{
Id = 22,
CompanyId = 22,
CompanyName = "Comped Co",
PrimaryContactName = "Owner",
PrimaryContactEmail = "comped@example.com",
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-30),
IsActive = true,
IsComped = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var status = await service.GetStatusAsync(22);
Assert.Equal(SubscriptionStatus.Active, status);
}
[Fact]
public async Task IsAiInventoryAssistEnabledAsync_RequiresCompanyToggle()
{
await using var context = CreateContext();
SeedCompanyAndPlan(context, companyId: 13, plan: 7, allowAiInventoryAssist: true);
var company = await context.Companies.FindAsync(13);
company!.AiInventoryAssistEnabled = false;
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var enabled = await service.IsAiInventoryAssistEnabledAsync(13);
Assert.False(enabled);
}
[Fact]
public async Task GetJobPhotoCountAsync_ExcludesAiAnalysisPhotos()
{
await using var context = CreateContext();
SeedCompanyAndPlan(context, companyId: 14, plan: 8, maxJobPhotos: 5);
context.JobPhotos.AddRange(
new JobPhoto
{
Id = 1,
CompanyId = 14,
JobId = 100,
FilePath = "jobs/100/1.jpg",
FileName = "1.jpg",
FileSize = 100,
ContentType = "image/jpeg",
UploadedById = "u1"
},
new JobPhoto
{
Id = 2,
CompanyId = 14,
JobId = 100,
FilePath = "jobs/100/2.jpg",
FileName = "2.jpg",
FileSize = 100,
ContentType = "image/jpeg",
UploadedById = "u1",
IsAiAnalysisPhoto = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var (used, max) = await service.GetJobPhotoCountAsync(14, 100);
Assert.Equal(1, used);
Assert.Equal(5, max);
}
[Fact]
public async Task GetQuotePhotoCountAsync_ExcludesAiAnalysisPhotos()
{
await using var context = CreateContext();
SeedCompanyAndPlan(context, companyId: 15, plan: 9, maxQuotePhotos: 4);
context.QuotePhotos.AddRange(
new QuotePhoto
{
Id = 1,
CompanyId = 15,
QuoteId = 200,
TempId = "temp-1",
FilePath = "quotes/200/1.jpg",
FileName = "1.jpg",
FileSize = 100,
ContentType = "image/jpeg",
IsAiAnalysisPhoto = false
},
new QuotePhoto
{
Id = 2,
CompanyId = 15,
QuoteId = 200,
TempId = "temp-2",
FilePath = "quotes/200/2.jpg",
FileName = "2.jpg",
FileSize = 100,
ContentType = "image/jpeg",
IsAiAnalysisPhoto = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var (used, max) = await service.GetQuotePhotoCountAsync(15, 200);
Assert.Equal(1, used);
Assert.Equal(4, max);
}
[Fact]
public async Task CanUseAiPhotoQuoteAsync_ReturnsFalse_WhenUsageEqualsLimit()
{
await using var context = CreateContext();
SeedCompanyAndPlan(context, companyId: 16, plan: 10, maxAiPhotoQuotesPerMonth: 2, allowAiPhotoQuotes: true);
context.AiItemPredictions.AddRange(
new AiItemPrediction { Id = 1, CompanyId = 16, CreatedAt = DateTime.UtcNow.AddDays(-1) },
new AiItemPrediction { Id = 2, CompanyId = 16, CreatedAt = DateTime.UtcNow.AddDays(-2) });
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var allowed = await service.CanUseAiPhotoQuoteAsync(16);
Assert.False(allowed);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
@@ -148,7 +333,10 @@ public class SubscriptionServiceTests
int maxCustomers = -1,
int maxQuotes = -1,
int maxAiPhotoQuotesPerMonth = -1,
bool allowAiPhotoQuotes = true)
int maxJobPhotos = -1,
int maxQuotePhotos = -1,
bool allowAiPhotoQuotes = true,
bool allowAiInventoryAssist = true)
{
context.Companies.Add(new Company
{
@@ -173,8 +361,11 @@ public class SubscriptionServiceTests
MaxActiveJobs = maxActiveJobs,
MaxCustomers = maxCustomers,
MaxQuotes = maxQuotes,
MaxJobPhotos = maxJobPhotos,
MaxQuotePhotos = maxQuotePhotos,
MaxAiPhotoQuotesPerMonth = maxAiPhotoQuotesPerMonth,
AllowAiPhotoQuotes = allowAiPhotoQuotes
AllowAiPhotoQuotes = allowAiPhotoQuotes,
AllowAiInventoryAssist = allowAiInventoryAssist
});
context.JobPriorityLookups.Add(new JobPriorityLookup
@@ -0,0 +1,300 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Moq;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Services;
namespace PowderCoating.UnitTests;
public class TenantContextTests
{
[Fact]
public void GetCurrentCompanyId_WhenUnauthenticated_ReturnsNull()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
var accessor = CreateHttpContextAccessor(new ClaimsPrincipal(new ClaimsIdentity()));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var companyId = tenantContext.GetCurrentCompanyId();
Assert.Null(companyId);
}
[Fact]
public void GetCurrentCompanyId_WhenSuperAdminIsImpersonating_ReturnsSessionOverride()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
var session = new TestSession();
session.SetInt32("ImpersonatingCompanyId", 42);
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "admin@example.com", roles: ["SuperAdmin"]),
session);
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var companyId = tenantContext.GetCurrentCompanyId();
Assert.Equal(42, companyId);
}
[Fact]
public void GetCurrentCompanyId_PrefersCompanyClaim()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.Users).Returns(Enumerable.Empty<ApplicationUser>().AsQueryable());
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "user@example.com", companyIdClaim: 9));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var companyId = tenantContext.GetCurrentCompanyId();
Assert.Equal(9, companyId);
}
[Fact]
public async Task GetCurrentCompanyId_WhenClaimMissing_FallsBackToUserLookup()
{
await using var context = CreateContext();
context.Users.Add(new ApplicationUser
{
Id = "user-1",
UserName = "legacy@example.com",
Email = "legacy@example.com",
FirstName = "Legacy",
LastName = "User",
CompanyId = 17
});
await context.SaveChangesAsync();
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.Users).Returns(context.Users);
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "legacy@example.com"));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var companyId = tenantContext.GetCurrentCompanyId();
Assert.Equal(17, companyId);
}
[Fact]
public void IsPlatformAdmin_ReturnsTrue_ForSuperAdminWithoutTenantScope()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.Users).Returns(Enumerable.Empty<ApplicationUser>().AsQueryable());
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, roles: ["SuperAdmin"]));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var isPlatformAdmin = tenantContext.IsPlatformAdmin();
Assert.True(isPlatformAdmin);
}
[Fact]
public void IsPlatformAdmin_ReturnsFalse_ForSuperAdminImpersonatingCompany()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
var session = new TestSession();
session.SetInt32("ImpersonatingCompanyId", 2);
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "admin@example.com", roles: ["SuperAdmin"]),
session);
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var isPlatformAdmin = tenantContext.IsPlatformAdmin();
Assert.False(isPlatformAdmin);
}
[Fact]
public async Task UseMetricSystemAsync_ReturnsStoredPreference()
{
await using var context = CreateContext();
context.CompanyPreferences.Add(new CompanyPreferences
{
Id = 1,
CompanyId = 25,
UseMetricSystem = true
});
await context.SaveChangesAsync();
var userManager = CreateUserManagerMock();
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "metric@example.com", companyIdClaim: 25));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var useMetric = await tenantContext.UseMetricSystemAsync();
Assert.True(useMetric);
}
[Fact]
public async Task GetCurrentCompanyAsync_ReturnsCompanyFromUserManager()
{
await using var context = CreateContext();
var company = new Company
{
Id = 31,
CompanyId = 31,
CompanyName = "Current Co",
PrimaryContactName = "Owner",
PrimaryContactEmail = "owner@example.com"
};
var principal = CreatePrincipal(isAuthenticated: true, name: "current@example.com", companyIdClaim: 31);
var user = new ApplicationUser
{
Id = "user-31",
UserName = "current@example.com",
Email = "current@example.com",
FirstName = "Current",
LastName = "User",
CompanyId = 31,
Company = company
};
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.GetUserAsync(principal)).ReturnsAsync(user);
var accessor = CreateHttpContextAccessor(principal);
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var currentCompany = await tenantContext.GetCurrentCompanyAsync();
Assert.NotNull(currentCompany);
Assert.Equal("Current Co", currentCompany!.CompanyName);
}
[Fact]
public void IsPlatformAdmin_ReturnsTrue_ForSuperAdminOnCompanyOne()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "platform@example.com", companyIdClaim: 1, roles: ["SuperAdmin"]));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var isPlatformAdmin = tenantContext.IsPlatformAdmin();
Assert.True(isPlatformAdmin);
}
[Fact]
public async Task UseMetricSystemAsync_WhenNoCompanyContext_ReturnsFalse()
{
await using var context = CreateContext();
var userManager = CreateUserManagerMock();
var accessor = CreateHttpContextAccessor(CreatePrincipal(isAuthenticated: true, name: "nocompany@example.com"));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var useMetric = await tenantContext.UseMetricSystemAsync();
Assert.False(useMetric);
}
[Fact]
public async Task GetCurrentCompanyAsync_WhenUnauthenticated_ReturnsNull()
{
await using var context = CreateContext();
var userManager = CreateUserManagerMock();
var accessor = CreateHttpContextAccessor(new ClaimsPrincipal(new ClaimsIdentity()));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var currentCompany = await tenantContext.GetCurrentCompanyAsync();
Assert.Null(currentCompany);
}
private static Mock<IHttpContextAccessor> CreateHttpContextAccessor(ClaimsPrincipal principal, ISession? session = null)
{
var httpContext = new Mock<HttpContext>();
httpContext.SetupGet(x => x.User).Returns(principal);
httpContext.SetupGet(x => x.Session).Returns(session ?? new TestSession());
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(x => x.HttpContext).Returns(httpContext.Object);
return accessor;
}
private static ClaimsPrincipal CreatePrincipal(
bool isAuthenticated,
string? name = null,
int? companyIdClaim = null,
string[]? roles = null)
{
if (!isAuthenticated)
{
return new ClaimsPrincipal(new ClaimsIdentity());
}
var claims = new List<Claim>();
if (!string.IsNullOrWhiteSpace(name))
{
claims.Add(new Claim(ClaimTypes.Name, name));
}
if (companyIdClaim.HasValue)
{
claims.Add(new Claim("CompanyId", companyIdClaim.Value.ToString()));
}
foreach (var role in roles ?? [])
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
var identity = new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role);
return new ClaimsPrincipal(identity);
}
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private sealed class TestSession : ISession
{
private readonly Dictionary<string, byte[]> _values = new();
public IEnumerable<string> Keys => _values.Keys;
public string Id => "test-session";
public bool IsAvailable => true;
public void Clear() => _values.Clear();
public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public void Remove(string key) => _values.Remove(key);
public void Set(string key, byte[] value) => _values[key] = value;
public bool TryGetValue(string key, out byte[] value) => _values.TryGetValue(key, out value!);
}
}
@@ -0,0 +1,329 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
public class UsageQuotaControllerTests
{
[Fact]
public async Task Index_UsesOverridesAndCountsOnlyActiveResources()
{
await using var context = CreateContext();
SeedPlan(context, plan: 1, maxUsers: 10, maxJobs: 5, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10);
SeedCompany(context, companyId: 1, plan: 1, maxUsersOverride: 5);
SeedLookupRows(context, companyId: 1);
context.Users.AddRange(
CreateUser("u1", 1),
CreateUser("u2", 1),
CreateUser("u3", 1),
CreateUser("u4", 1));
context.Customers.AddRange(
new Customer { Id = 1, CompanyId = 1, CompanyName = "Cust 1" },
new Customer { Id = 2, CompanyId = 1, CompanyName = "Cust 2" });
context.Jobs.AddRange(
new Job { Id = 1, CompanyId = 1, CustomerId = 1, Description = "Active", JobNumber = "JOB-1", JobStatusId = 10, JobPriorityId = 1 },
new Job { Id = 2, CompanyId = 1, CustomerId = 1, Description = "Done", JobNumber = "JOB-2", JobStatusId = 11, JobPriorityId = 1 });
context.Quotes.AddRange(
new Quote { Id = 1, CompanyId = 1, QuoteNumber = "Q-1", QuoteStatusId = 10 },
new Quote { Id = 2, CompanyId = 1, QuoteNumber = "Q-2", QuoteStatusId = 11 },
new Quote { Id = 3, CompanyId = 1, QuoteNumber = "Q-3", QuoteStatusId = 12 });
context.CatalogItems.AddRange(
new CatalogItem { Id = 1, CompanyId = 1, Name = "Wheel", CategoryId = 1 },
new CatalogItem { Id = 2, CompanyId = 1, Name = "Frame", CategoryId = 1 });
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var result = await controller.Index(null, null, null, null);
var view = Assert.IsType<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(view.Model));
Assert.Equal(4, row.Users);
Assert.Equal(5, row.MaxUsers);
Assert.Equal(1, row.ActiveJobs);
Assert.Equal(1, row.ActiveQuotes);
Assert.Equal(2, row.Customers);
Assert.Equal(2, row.CatalogItems);
Assert.True(row.IsNearLimit);
Assert.False(row.IsAtLimit);
Assert.Equal(0, controller.ViewBag.AtLimitCount);
Assert.Equal(1, controller.ViewBag.NearLimitCount);
}
[Fact]
public async Task Index_ForCompedCompany_ReturnsUnlimitedLimitsWithoutFlags()
{
await using var context = CreateContext();
SeedPlan(context, plan: 2, maxUsers: 1, maxJobs: 1, maxCustomers: 1, maxQuotes: 1, maxCatalogItems: 1);
SeedCompany(context, companyId: 2, plan: 2, isComped: true);
SeedLookupRows(context, companyId: 2);
context.Users.AddRange(CreateUser("u1", 2), CreateUser("u2", 2));
context.Customers.Add(new Customer { Id = 10, CompanyId = 2, CompanyName = "Comped Customer" });
context.Jobs.Add(new Job { Id = 10, CompanyId = 2, CustomerId = 10, Description = "Active", JobNumber = "JOB-C", JobStatusId = 20, JobPriorityId = 2 });
context.Quotes.Add(new Quote { Id = 10, CompanyId = 2, QuoteNumber = "Q-C", QuoteStatusId = 20 });
context.CatalogItems.Add(new CatalogItem { Id = 10, CompanyId = 2, Name = "Rack", CategoryId = 1 });
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var result = await controller.Index(null, null, null, null);
var view = Assert.IsType<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(view.Model));
Assert.True(row.IsComped);
Assert.Equal(-1, row.MaxUsers);
Assert.Equal(-1, row.MaxActiveJobs);
Assert.Equal(-1, row.MaxCustomers);
Assert.Equal(-1, row.MaxActiveQuotes);
Assert.Equal(-1, row.MaxCatalogItems);
Assert.False(row.IsNearLimit);
Assert.False(row.IsAtLimit);
}
[Fact]
public async Task Index_ConcernFilters_SeparateNearAndAtLimitRows()
{
await using var context = CreateContext();
SeedPlan(context, plan: 3, maxUsers: 5, maxJobs: 5, maxCustomers: 5, maxQuotes: 5, maxCatalogItems: 5);
SeedCompany(context, companyId: 3, plan: 3, companyName: "Near Co");
SeedCompany(context, companyId: 4, plan: 3, companyName: "At Co");
SeedCompany(context, companyId: 5, plan: 3, companyName: "Safe Co");
SeedLookupRows(context, 3);
SeedLookupRows(context, 4);
SeedLookupRows(context, 5);
context.Users.AddRange(
CreateUser("n1", 3), CreateUser("n2", 3), CreateUser("n3", 3), CreateUser("n4", 3),
CreateUser("a1", 4), CreateUser("a2", 4), CreateUser("a3", 4), CreateUser("a4", 4), CreateUser("a5", 4),
CreateUser("s1", 5));
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var limitResult = await controller.Index(null, null, null, "limit");
var limitRows = Assert.IsAssignableFrom<List<UsageRow>>(Assert.IsType<ViewResult>(limitResult).Model);
Assert.Equal(2, limitRows.Count);
Assert.Contains(limitRows, r => r.CompanyName == "Near Co" && r.IsNearLimit);
Assert.Contains(limitRows, r => r.CompanyName == "At Co" && r.IsAtLimit);
var atLimitResult = await controller.Index(null, null, null, "atlimit");
var atLimitRows = Assert.IsAssignableFrom<List<UsageRow>>(Assert.IsType<ViewResult>(atLimitResult).Model);
var atLimitRow = Assert.Single(atLimitRows);
Assert.Equal("At Co", atLimitRow.CompanyName);
Assert.True(atLimitRow.IsAtLimit);
}
[Fact]
public async Task Index_AppliesSearchStatusAndPlanFilters()
{
await using var context = CreateContext();
SeedPlan(context, plan: 6, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10, displayName: "Plan Six");
SeedPlan(context, plan: 7, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10, displayName: "Plan Seven");
SeedCompany(context, companyId: 6, plan: 6, companyName: "Acme Powder", status: SubscriptionStatus.Active);
SeedCompany(context, companyId: 7, plan: 6, companyName: "Beta Powder", status: SubscriptionStatus.Expired);
SeedCompany(context, companyId: 8, plan: 7, companyName: "Acme East", status: SubscriptionStatus.Active);
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var result = await controller.Index("Acme", nameof(SubscriptionStatus.Active), "6", null);
var view = Assert.IsType<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(view.Model));
Assert.Equal("Acme Powder", row.CompanyName);
Assert.Equal(nameof(SubscriptionStatus.Active), controller.ViewBag.StatusFilter);
Assert.Equal("6", controller.ViewBag.PlanFilter);
}
[Fact]
public async Task Index_WhenUsageIsExactlyEightyPercent_MarksRowNearLimit()
{
await using var context = CreateContext();
SeedPlan(context, plan: 8, maxUsers: 5, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10);
SeedCompany(context, companyId: 9, plan: 8, companyName: "Threshold Co");
await context.SaveChangesAsync();
context.Users.AddRange(
CreateUser("t1", 9),
CreateUser("t2", 9),
CreateUser("t3", 9),
CreateUser("t4", 9));
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var result = await controller.Index(null, null, null, null);
var view = Assert.IsType<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(view.Model));
Assert.Equal(4, row.Users);
Assert.Equal(5, row.MaxUsers);
Assert.True(row.IsNearLimit);
Assert.False(row.IsAtLimit);
}
[Fact]
public async Task Index_WhenFiltersAreInvalid_IgnoresThem()
{
await using var context = CreateContext();
SeedPlan(context, plan: 9, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10);
SeedPlan(context, plan: 10, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10);
SeedCompany(context, companyId: 10, plan: 9, companyName: "Alpha Co", status: SubscriptionStatus.Active);
SeedCompany(context, companyId: 11, plan: 10, companyName: "Beta Co", status: SubscriptionStatus.Expired);
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var result = await controller.Index(null, "NotARealStatus", "not-a-plan", null);
var view = Assert.IsType<ViewResult>(result);
var rows = Assert.IsAssignableFrom<List<UsageRow>>(view.Model);
Assert.Equal(2, rows.Count);
Assert.Equal(2, controller.ViewBag.TotalCount);
Assert.Equal("NotARealStatus", controller.ViewBag.StatusFilter);
Assert.Equal("not-a-plan", controller.ViewBag.PlanFilter);
}
private static ApplicationUser CreateUser(string id, int companyId)
{
return new ApplicationUser
{
Id = id,
CompanyId = companyId,
UserName = $"{id}@example.com",
Email = $"{id}@example.com",
FirstName = "Test",
LastName = "User"
};
}
private static void SeedPlan(
ApplicationDbContext context,
int plan,
int maxUsers,
int maxJobs,
int maxCustomers,
int maxQuotes,
int maxCatalogItems,
string? displayName = null)
{
context.SubscriptionPlanConfigs.Add(new SubscriptionPlanConfig
{
Id = plan,
CompanyId = 0,
Plan = plan,
DisplayName = displayName ?? $"Plan {plan}",
SortOrder = plan,
IsActive = true,
MaxUsers = maxUsers,
MaxActiveJobs = maxJobs,
MaxCustomers = maxCustomers,
MaxQuotes = maxQuotes,
MaxCatalogItems = maxCatalogItems
});
}
private static void SeedCompany(
ApplicationDbContext context,
int companyId,
int plan,
string? companyName = null,
SubscriptionStatus status = SubscriptionStatus.Active,
bool isComped = false,
int? maxUsersOverride = null)
{
context.Companies.Add(new Company
{
Id = companyId,
CompanyId = companyId,
CompanyName = companyName ?? $"Company {companyId}",
PrimaryContactName = "Owner",
PrimaryContactEmail = $"owner{companyId}@example.com",
SubscriptionPlan = plan,
SubscriptionStatus = status,
IsComped = isComped,
MaxUsersOverride = maxUsersOverride,
IsActive = true
});
}
private static void SeedLookupRows(ApplicationDbContext context, int companyId)
{
context.JobPriorityLookups.Add(new JobPriorityLookup
{
Id = companyId,
CompanyId = companyId,
PriorityCode = "NORMAL",
DisplayName = "Normal",
DisplayOrder = 1
});
context.JobStatusLookups.AddRange(
new JobStatusLookup
{
Id = companyId * 10,
CompanyId = companyId,
StatusCode = "ACTIVE",
DisplayName = "Active",
DisplayOrder = 1,
IsTerminalStatus = false
},
new JobStatusLookup
{
Id = companyId * 10 + 1,
CompanyId = companyId,
StatusCode = "DONE",
DisplayName = "Done",
DisplayOrder = 2,
IsTerminalStatus = true
});
context.QuoteStatusLookups.AddRange(
new QuoteStatusLookup
{
Id = companyId * 10,
CompanyId = companyId,
StatusCode = "PENDING",
DisplayName = "Pending",
DisplayOrder = 1
},
new QuoteStatusLookup
{
Id = companyId * 10 + 1,
CompanyId = companyId,
StatusCode = "REJECTED",
DisplayName = "Rejected",
DisplayOrder = 2,
IsRejectedStatus = true
},
new QuoteStatusLookup
{
Id = companyId * 10 + 2,
CompanyId = companyId,
StatusCode = "CONVERTED",
DisplayName = "Converted",
DisplayOrder = 3,
IsConvertedStatus = true
});
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}