Compare commits

..

70 Commits

Author SHA1 Message Date
spouliot 8148908a66 Merge branch 'dev' 2026-05-02 19:31:28 -04:00
spouliot c18b580ec9 Add SMS Agreements admin page and update help docs
- Add /SmsAgreements SuperAdmin page listing per-company SMS terms acceptance
  status, with stats cards, filter/search, and a full acceptance history modal
  (terms version, accepted by, timestamp, IP, user agent)
- Add SMS Agreements nav link under Tenants & Billing in the platform sidebar
- Update HelpKnowledgeBase and Help docs (Quotes, Settings) to document
  quote approval via SMS and the reuse of existing approval tokens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 10:17:11 -04:00
spouliot a2d48c8b58 Add SMS quote approval, fix Twilio credentials, fix passkey post-login redirect
- Add 'Send Quote via SMS' button on quote details page that sends the approval
  link to the customer via SMS (respects NotifyBySms, handles prospects via ProspectPhone)
- Reuses existing valid approval token rather than regenerating, so a previously
  emailed link stays valid when SMS is also sent
- Fix Twilio appsettings.json placeholders (real credentials moved to gitignored
  appsettings.Development.json)
- Fix passkey login ignoring ReturnUrl: biometric login on the login page now
  respects the form's ReturnUrl hidden field so QR-code and deep-link flows
  redirect correctly after authentication instead of always going to the dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 09:38:55 -04:00
spouliot d9bf80cc9a Update help docs and AI knowledge base for SMS notifications
- Settings help article: add SMS Notifications subsection covering company opt-in, TCPA terms agreement, compose-before-send vs auto-send, and STOP opt-out
- Customers help article: add Mobile Phone and SMS Opt-In fields to the Adding a Customer field list
- HelpKnowledgeBase: remove stale "ready for pickup" SMS event, add company opt-in flow, customer opt-in requirement, compose modal for Admin/Manager, auto-send for ShopFloor, Send SMS button, and STOP opt-out behavior; remove SuperAdmin-only SMS Platform Setting content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 22:32:55 -04:00
spouliot 6569d9c4ea Add SMS gating, TCPA terms agreement, and compose-before-send modal
- Three-tier SMS gate: platform kill-switch → admin force-disable → plan AllowSms → company opt-in
- CompanySmsAgreement entity records admin acceptance of TCPA terms with IP, user agent, and terms version
- SMS terms of service modal on Company Settings with versioned re-agreement (AppConstants.SmsTermsVersion)
- Dev redirect: non-production SMS routed to Twilio:DevRedirectPhone to protect real customer numbers
- Removed redundant Ready for Pickup SMS (Job Completed covers it)
- Role-based compose modal on job completion: Admin/Manager reviews and edits before send; ShopFloor auto-sends
- Send SMS button on job details for ad-hoc messages (Admin/Manager only)
- SendJobSmsAsync auto-appends STOP opt-out language if missing
- Migrations: AddSmsGating, AddCompanySmsAgreement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 22:29:39 -04:00
spouliot 2b89fcf483 Refactor dashboard queries to push filtering and aggregation into the database
DashboardReadService no longer loads full entity lists and filters in memory.
All job panels (today/overdue/in-progress) now execute targeted COUNT + capped
SELECT queries in SQL. AR aging buckets, powder order lines, bill totals, and
active-customer counts are all aggregated at the DB level. The SuperAdmin action
previously loaded every company row to compute plan distribution and alert lists;
it now delegates to a new GetSuperAdminDashboardDataAsync() that uses SQL GROUP BY
and projections instead.

DashboardIndexData record updated to carry pre-sliced counts and capped lists so
the controller only does lightweight DTO projection. DashboardPowderOrderLineData
replaces the deep Job→JobItem→Coat Include chains with a single flat coat query
projected in SQL. OnlineUserMiddleware switches its per-user throttle from a
static ConcurrentDictionary (grows forever) to IMemoryCache with a 60-second
sliding expiry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:00:43 -04:00
spouliot a9a8ea41c6 Merge branch 'dev' 2026-04-30 08:23:40 -04:00
spouliot 0b798cadb4 Add Cancel button to inventory QR scan usage page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:26:39 -04:00
spouliot 49e3d73c67 Add click-to-enlarge lightbox for inventory product image
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:20:01 -04:00
spouliot 90a06c6acd Add product image to powder inventory via AI lookup
When AI Lookup fetches a manufacturer product page, it now extracts the
og:image (Open Graph) meta tag before stripping HTML tags. The image URL
is returned in InventoryAiLookupResult.ImageUrl and automatically shown
as a preview on the Create/Edit form alongside the other filled fields.

The preview includes a Remove button to clear the image, and the Wrong
Match? button clears it along with the other AI-filled fields.

On the inventory Details page a product image card is rendered above the
Stock & Pricing card whenever ImageUrl is set. The field is nullable so
existing records and powders without an image are unaffected.

New field: InventoryItem.ImageUrl (nvarchar, nullable).
Migration: AddInventoryItemImageUrl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 18:15:55 -04:00
spouliot 9221fcc783 Add quote-changed banner with re-sync to job details
When a source quote is edited after a job was created from it, the job
details page now shows a warning banner with the date of the change and
a link to the quote. Two actions are offered:

- Re-sync from Quote: replaces all job items, coats, prep services, and
  pricing from the current quote. Only available while the job is still
  in a pre-production status (Pending, Quoted, Approved); hidden once
  shop work has started (InPreparation or beyond).
- Dismiss: acknowledges the change without altering the job, clearing
  the banner by advancing the stored snapshot timestamp.

Implemented via Job.QuoteSnapshotUpdatedAt (new nullable column), set at
quote→job conversion time. The banner fires when quote.UpdatedAt exceeds
this baseline. Migration: AddJobQuoteSnapshotUpdatedAt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 18:02:46 -04:00
spouliot 167dc0c146 Merge dev into master 2026-04-29 13:37:26 -04:00
spouliot ac3e4452b2 Fix catalog filter leaving items unclickable after filtering
Multi-word search now uses Array.every so word order doesn't matter.
Scroll is reset to top after each filter so visible items aren't hidden
off-screen. A forced layout reflow (offsetHeight read) ensures iOS Safari
re-registers filtered-in elements as interactive — without it, items that
transition from display:none back to visible remain unresponsive to taps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:19:41 -04:00
spouliot 3669fda852 Merge branch 'dev' 2026-04-29 09:23:26 -04:00
spouliot 8de9cd04b8 Add server-side dismiss persistence and SuperAdmin onboarding progress page
Progress widget dismiss now POSTs to Dashboard/DismissProgressWidget, writing
GuidedActivationDismissedAt to the DB so the widget stays hidden across devices
and cache clears (localStorage alone wasn't enough). BuildShopProgressWidgetAsync
suppresses the widget server-side when AllDone + dismissed.

New SuperAdmin page at /OnboardingProgress shows the activation funnel across
all tenant companies: wizard status, chosen path, milestone progress bar, key
dates (first job/quote, first invoice, workflow completed, widget dismissed),
and a status badge (Not Started / In Progress / Complete / Dismissed). Nav link
added under Users & Activity in the Platform Management sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 09:23:20 -04:00
spouliot 296f85e33b Fix progress widget 'Set how you get paid' link pointing to non-existent #general tab
Payment terms lives in the App Defaults tab (#app-defaults), not #general.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:41:07 -04:00
spouliot 900a52f89d Merge branch 'dev' 2026-04-29 08:11:37 -04:00
spouliot 73df72ab97 Add dismiss button to progress widget completion state
Shows an × button in the top-right of the completion card so users can
permanently hide the widget once they've seen the success message. Dismissal
is stored in localStorage (same pattern as the collapse state) so it persists
across page loads without requiring a DB migration. The widget hides itself
on the next load before any layout is shown, avoiding a flash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:37:53 -04:00
spouliot 45441c1d07 Fix 'Customize your workflow' done signal not detecting deletions
The previous AnyAsync check used global query filters which hide
soft-deleted records. Deleting a lookup sets UpdatedAt on the record
(EF interceptor stamps Modified entities) but the IsDeleted filter
made it invisible to the query. Added ignoreQueryFilters: true with
an explicit CompanyId predicate so soft-deleted lookups are included —
any deletion or edit now correctly marks the step complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:25:30 -04:00
spouliot 64e9abceac Hide team invite step on progress widget for single-user plans
Injects ISubscriptionService into DashboardController and calls
GetUserCountAsync to check the plan's MaxUsers limit. When MaxUsers == 1
the "Bring your crew in" step is omitted from the progress widget entirely,
so solo-plan users aren't prompted to do something their subscription
doesn't allow. Plans with MaxUsers > 1 or unlimited (-1) show the step
as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:22:41 -04:00
spouliot b1337d3b61 Update help docs and AI knowledge base for onboarding overhaul
GettingStarted.cshtml: updated Setup Wizard description from 10-step to
5-step, revised time estimate (5–10 min), added new "After the Wizard"
section explaining the guided activation first-workflow flow and the
progress widget (what it tracks, how the highlight works, when it
disappears, Admin-only visibility).

HelpKnowledgeBase.cs (AI assistant): updated Setup Wizard entry to 5 steps,
replaced old step list (removed steps 5–8, 10), updated new company
quick-start checklist to reference the 5-step wizard and the post-wizard
progress widget. Added new GUIDED ACTIVATION section covering the two
onboarding paths (Quote First / Job First), the Daily Board intro experience,
and how the banner auto-dismisses. Updated Common Workflows to reflect the
new onboarding order. Dashboard section now describes the progress widget
and its six tracked steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:10:59 -04:00
spouliot 8aae30765f Onboarding overhaul: slim wizard, progress widget, guided activation UX
Setup Wizard: reduced from 10 steps to 5 (Company Info → QB Migration →
Pricing Defaults → Named Ovens → Notifications). Removed Doc Numbering,
Job Settings, Payment Terms, Pricing Tiers, and Team Members steps — these
all have sensible defaults and are accessible any time in Company Settings.
Wizard now completes in ~5 minutes instead of 15–20.

Dashboard progress widget (new): "Get the most out of your shop" checklist
appears for Company Admins after wizard completion. Tracks six post-setup
activation tasks with dynamic progress badge, motivating subtitle copy,
collapsed-state persistence via localStorage, and a full completion state
("Your shop is fully set up 🎉") that replaces the checklist at 100%.
The next recommended step is highlighted with a solid CTA button and a
subtle blue row tint. Completed steps show encouraging green subtext instead
of just "Done". Widget disappears from controller when AllDone would have
caused a silent vanish — now renders the completion state instead.

Guided activation (Daily Board): rewrote the BoardIntroStep callout to lead
with "This is your shop in real time" and a plain-English description of the
board's purpose. Added a separate InstructionText field to
GuidedActivationCalloutViewModel so the "Move this job to the next stage"
action prompt renders as a distinct bold line with an arrow icon rather than
being buried in the body copy. After the stage change, the confirmation
callout now reads "Nice — your workflow just updated" to reinforce what just
happened before prompting the invoice step.

All copy passes the "shop owner, not SaaS" test: no technical jargon,
benefit-driven descriptions, natural language throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 21:10:47 -04:00
spouliot 4d27a378ac Fix QuoteApprovalControllerTests build break after Phase 3 migration
QuoteApprovalController's constructor changed from ApplicationDbContext to
IUnitOfWork in Phase 3, but the test helper was still passing the raw context.
Wrap context in UnitOfWork in CreateController; tests seed/assert through
context directly so the same instance works correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:35:30 -04:00
spouliot 6993c2c462 Fix invoice detail crash after first credit memo or refund is applied
AutoMapper 12+ throws AutoMapperMappingException when mapping a non-empty
collection for which no element type map is registered. Invoice.CreditApplications
and Invoice.Refunds had no CreateMap entries, so the invoice Details view worked
fine until the first credit or refund existed — at that point AutoMapper tried
to map the element type and threw, causing the catch block to redirect to the
invoice list with a generic "failed to load" error.

Fix: mark CreditApplications and Refunds as Ignore() in the Invoice->InvoiceDto
AutoMapper profile. Both collections are already built manually in
BuildInvoiceDtoAsync, matching the existing GiftCertificateRedemptions pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:17:38 -04:00
spouliot 1cb7a8ca4a Phases 3 & 4: Complete data access architecture migration
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:17:29 -04:00
spouliot 90bc0d965f Phase 2: Eliminate ApplicationDbContext from domain controllers
Migrated InvoicesController, QuotesController, JobsController, BillsController,
PurchaseOrdersController, and CustomersController to route all data access
through IUnitOfWork typed/generic repositories instead of injecting
ApplicationDbContext directly.

New typed repositories added: IJobRepository (GetScheduledJobsForDateAsync,
GetActiveJobsForMobileAsync, LoadForCostingAsync), INotificationLogRepository
(GetLatestForJobAsync, GetAllForJobAsync), IQuoteRepository (GetItemsWithCoatsAsync
with CatalogItem eager load + AsNoTracking), and IJobRepository.GetOrphanedConversionJobAsync.

All EF complex include chains relocated into repository methods; controllers now
call named query methods rather than composing raw IQueryable chains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 21:20:39 -04:00
spouliot 80b0e547cc Phase 1: Introduce typed repository interfaces and report service stubs
Six IUnitOfWork properties upgraded from generic IRepository<T> to domain-specific
typed interfaces (IJobRepository, IQuoteRepository, IInvoiceRepository,
ICustomerRepository, IBillRepository, IPurchaseOrderRepository). Each backed by a
concrete typed repository that encapsulates complex include chains previously
inlined in controllers.

Also adds IFinancialReportService and IOperationalReportService stub implementations
(NotImplementedException placeholders) to Application.Interfaces and Infrastructure.Services,
registered in Program.cs. These are the migration targets for ReportsController's
aggregate query methods in Phase 2.

No controller behaviour changed in this commit — all callers still compile because
typed interfaces extend IRepository<T>.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:54:10 -04:00
spouliot 92dc3ebd08 Add data access architecture spec and enforce rules in CLAUDE.md
Defines the target architecture for eliminating direct ApplicationDbContext
injection from controllers. Documents the three-tier model (generic repo,
typed domain repos, read services), the 6 typed repository interfaces to
build, the 2 reporting service interfaces to build, permanent exceptions,
and the 4-phase migration roadmap with per-controller checklist.

CLAUDE.md updated with the hard rule and tier quick-reference so every
session and every team member sees the constraint immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:35:16 -04:00
spouliot 5631d1d57a Add WarningPermanent toast type and upgrade invoice failure notifications
Email delivery failures and PDF generation errors now show a permanent
warning/error toast that requires manual dismissal, so users cannot
accidentally miss critical action-blocking feedback.

- ToastHelper: WarningPermanent TempData key + Warning/WarningPermanent
  extension methods on both ITempDataDictionary and Controller
- SetNotificationResultToast: NotificationStatus.Failed now uses
  ToastWarningPermanent (previously auto-dismissed in 5 s)
- InvoicesController.Send: TempData["Warning"] → TempData["WarningPermanent"]
  when PDF generation or email dispatch fails
- InvoicesController.DownloadPdf: TempData["Error"] → TempData["ErrorPermanent"]
  with the actual exception message so root cause is visible
- _Layout.cshtml: WarningPermanent hidden div
- toast-notifications.js: WarningPermanent handler (timeOut: 0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:02:16 -04:00
spouliot cad728ba66 Fix passkey login tracking, add email opt-out UI guards, and add Quick/Full quote mode toggle
- PasskeyController: set LastLoginDate on passkey sign-in so Company Health
  and audit pages show accurate last-login times (was always showing 'Never')
- Jobs/Index status modal: disable 'Notify customer' email toggle and show
  warning when customer has notifications turned off; CustomerNotifyByEmail
  added to JobListDto + JobProfile mapping + data-customer-notify attribute
- Quotes/Create: disable 'Send quote via email' checkbox with 'Notifications
  off' badge when selected customer has email opt-out; ViewBag.CustomerEmailOptOutIds
  added alongside existing CustomerTaxExemptIds pattern
- Quotes/Create: Quick Quote / Full Quote segmented toggle at top of form;
  hides non-essential fields (dates, notes, tags, oven, discount, photos) in
  Quick mode; selection persisted in localStorage
- InvoicesController Send action: improved error logging and user-facing
  warning when PDF generation or email dispatch fails after status is saved
- item-wizard.js: guard item restoration with try/catch; ensure writeHiddenFields
  always runs on form submit via capture-phase listener
- Help docs and AI knowledge base updated for all new features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:32:34 -04:00
spouliot 0ea192d55b Harden legacy file paths and Twilio webhook validation 2026-04-26 18:14:16 -04:00
spouliot 8491b308eb Add admin email wizard and logging 2026-04-26 17:01:09 -04:00
spouliot 404ab3c45d Add unit tests for LedgerService, AccountBalanceService, DepositsController, and GiftCertificatesController
181 tests total passing. Covers all 9 LedgerService transaction sources, debit/credit
balance mechanics, AR/AP movements, date range filtering, running balance computation,
and future-dated opening balance exclusion. Also covers deposit recording/deletion,
gift certificate lifecycle (issue, void, lazy expiry), and account balance recalculation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:48:34 -04:00
spouliot 90a01571e3 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>
2026-04-26 10:35:29 -04:00
spouliot a4b8ae611a Add passkey prompt dismissal and consolidate company admin navigation
- Add "Don't ask me again" to passkey enrollment prompt (PasskeyPromptDismissed
  field on ApplicationUser; DismissPrompt POST action; migration applied)
- Add Subscription & Features button to Companies/Index btn-group and
  Companies/Edit header for direct navigation to SubscriptionManagement/Manage
- Add Edit Company back-link on SubscriptionManagement/Manage
- Remove duplicate AI Features section from Companies/Edit (managed exclusively
  via Subscription & Features page)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:34:50 -04:00
spouliot 3899860c1f Fix PlatformSettings insert collision in AddAiCatalogPriceCheckGating migration
Replace InsertData (hardcoded ID 9) with raw IF NOT EXISTS SQL so the
migration is safe on environments where ID 9 is already taken.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:30:28 -04:00
spouliot f03a198e79 Make AiCatalogPriceCheckEnabled a plan-override toggle
Company-level toggle now grants access regardless of plan tier, checked
before the plan gate. Useful for enabling the feature on individual
Pro/Basic companies without upgrading their plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:41:27 -04:00
spouliot cb7bbc37bd Add three-layer feature gating for AI Catalog Price Check
Adds platform-level, plan-level (Enterprise only), and per-company
toggles for the AI Catalog Price Check feature. Includes:
- Company.AiCatalogPriceCheckEnabled per-company flag
- SubscriptionPlanConfig.AllowAiCatalogPriceCheck plan-level flag
- PlatformSetting 'AiCatalogPriceCheckEnabled' global kill switch
- IPlatformSettingsService.GetBoolAsync helper
- ISubscriptionService.CanUseAiCatalogPriceCheckAsync
- UI controls in Companies/Edit, PlatformSubscription/Edit+Index,
  and SubscriptionManagement/Manage
- Migration AddAiCatalogPriceCheckGating applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 08:29:51 -04:00
spouliot fa9fa76231 Document AI Catalog Price Check in knowledge base and help article
- HelpKnowledgeBase: full AI price check section (verdicts, confidence,
  category paths, run limit, how to use, common questions)
- Inventory help article: new 'AI Catalog Price Check' section with
  verdicts, step-by-step instructions, caveats, and on-page nav link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:55:05 -04:00
spouliot 4128c15bbb Remove 'Claude' brand references from all user-facing views
Replace with generic 'AI', 'AI agent', or 'AI system' throughout.
Keeps the underlying vendor implementation details off the UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:51:36 -04:00
spouliot 6d9111b448 Rename button and add explanatory blurb to AI price check page
- Button: 'Run/Re-run Price Check' -> 'Analyze Catalog with AI'
- Add info card explaining what the analysis does, verdict meanings,
  and the disclaimer to verify operating costs before running

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:49:49 -04:00
spouliot 37c95192ca Enforce quarterly run limit on AI price check
- GET: sets ViewBag.NextRunAvailable if last run was within 90 days;
  view disables the button and shows the next eligible date
- POST: returns early with a warning if called before the 90-day window

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:48:39 -04:00
spouliot 03c10a3d77 Recalibrate progress bar to 27s/batch based on observed run time
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:45:36 -04:00
spouliot ff79c39e83 Switch to sequential batching to eliminate rate limit hits
1 concurrent + 20s pacing = ~3 batches/min × 2k tokens = 6k TPM,
safely under the 8k output TPM limit. Progress estimate updated to 22s/batch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:27:10 -04:00
spouliot 2d25f6db2b Add proactive inter-batch pacing to avoid rate limit hits
Rather than relying on reactive 65s retries, each semaphore slot is held
for at least MinBatchIntervalSeconds (20s). With 2 concurrent slots that
limits throughput to ~3 batches/min × ~2k tokens = ~6k output TPM,
safely under the 8k/min limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:01:22 -04:00
spouliot 47f186384f Increase progress bar estimate to account for rate-limit retry waits
25s per wave (was 10s) gives headroom for occasional 65s pauses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:55:17 -04:00
spouliot 26b8244422 Reduce to 2 concurrent batches to avoid Haiku output TPM bursting
3 concurrent batches hit the rate limit simultaneously then retry in
unison, causing repeated 429s. 2 concurrent keeps output rate lower.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:54:32 -04:00
spouliot 7b902d90a2 Restore 3 concurrent batches with Haiku; recalibrate progress bar
Haiku has generous rate limits so parallelism is safe again. Retry
logic catches any 429s. Progress estimate updated to ~8s per wave.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:49:53 -04:00
spouliot f05e16a826 Switch AI price check to Haiku for cost and speed
Testing Haiku 4.5 for catalog price analysis — structured JSON output
with explicit rules is well within its capabilities. Revert to Sonnet
if result quality is insufficient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:41:50 -04:00
spouliot 97d47dbd1c Fix progress bar timing for sequential batch processing
Was still estimating based on 3 concurrent waves (old model).
Sequential mode runs ~18s per batch, so 500 items ≈ 6 minutes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:13:39 -04:00
spouliot 7407d1cd96 Fix rate limit errors in AI price check
Tier 1 Anthropic accounts are capped at 8,000 output tokens/minute on
Sonnet. 3 concurrent batches burst well past that, causing 429s.

- MaxConcurrentBatches: 3 → 1 (sequential prevents burst)
- Add retry: on rate_limit_error, wait 65s then retry up to 3 times
  so the per-minute window resets before the next attempt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:54:30 -04:00
spouliot 740238a939 Drop description field from AI price check user prompt
Item name + category path give Claude sufficient context for surface area
estimation. Descriptions add input tokens without meaningfully improving
verdict quality.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:44:25 -04:00
spouliot 560a2c76b8 Add full category path to AI price check for coating-type context
- Skip $0-priced items (placeholders/category headers) in RunAiPriceCheck
- Build full category path (e.g. "Cerakote > Firearms") via BuildCategoryPath
  so Claude receives coating-type context — Cerakote pricing differs significantly
  from standard powder coat
- Update AI system prompt to instruct Claude to use the category path when
  determining process type, equipment, cure times, and market rates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:35:41 -04:00
spouliot 19cc03ad1c Parallelize AI price check batches, increase batch size to 25
500-item catalog was making 50 sequential API calls, causing progressive rate-limit
throttling (explains "super slow towards the end") and ~$3 in credits.

- BatchSize: 10 → 25 (word limits are in place; 25 items × ~80 tokens ≈ 2000
  output tokens, well within MaxTokens=8192 — the original truncation cause)
- Run up to 3 batches concurrently via SemaphoreSlim(3) — independent API calls
  with no shared state, so no growing context issue
- For a 500-item catalog: 50 sequential calls → 20 calls in ~7 parallel waves,
  roughly 4× faster and 60% cheaper
- Dropped unused `costs` param from AnalyzeBatchAsync (system prompt has all costs)
- JS progress timing updated to reflect parallel waves

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:27:07 -04:00
spouliot 9370fcdd8f Reduce batch size to 10 and tighten AI price check prompt
Still seeing stubs despite MaxTokens=8192 — smaller batches and explicit
word limits in the prompt eliminate any remaining truncation risk.

- BatchSize: 15 → 10 (~1200 output tokens per batch vs. potential 3000+)
- Prompt: added 20-word cap on assumptions, 25-word cap on reasoning
- Prompt: strengthened "nothing before or after the '['" instruction
- Error log: now includes item IDs and first 300 chars of raw response
  so the next failure tells us exactly what Claude returned
- JS timing: updated batch divisor from 25 → 10 to match actual batch size

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:57:23 -04:00
spouliot 2c4c1a6846 Fix AI price check truncation and JSON parse errors
Root cause: MaxTokens=4096 was too low — 25 items at ~250 tokens each hit the
limit mid-array (logged error showed Path: $[17]).

- MaxTokens: 4096 → 8192
- BatchSize: 25 → 15 items (keeps each response well under the limit)
- StripJsonFences → ExtractJsonArray: now also handles prose before/after the
  JSON array, and recovers truncated responses by finding the last complete
  object and closing the array — so partial batches return whatever Claude
  finished rather than nothing
- GET action: added try-catch around ResultsJson deserialization so a bad DB
  row shows a friendly "re-run" warning instead of a raw error page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:45:53 -04:00
spouliot c9324ee0b0 Fix catalog-price-check.js served from wrong wwwroot
File was written to repo-root wwwroot/ instead of
src/PowderCoating.Web/wwwroot/ — causing a 404 and MIME type refusal.
Moved to the correct location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:33:14 -04:00
spouliot 9943c11571 Add progress overlay to AI Catalog Price Check
Shows a modal overlay with animated progress bar and batch-aware status messages
while Claude is analyzing. Progress animates in two phases: ease-out to ~85%
over the estimated duration, then a slow crawl to 99% so it never falsely
"completes" before the server responds.

- Overlay driven by CSS (hidden until .active added by JS)
- Item count passed from controller as data-item-count on the run button
- Batch count derived from item count (batches of 25) to show accurate
  "Analyzing batch N of M…" messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:27:08 -04:00
spouliot 360edace72 Fix EnrollPrompt page layout squished on desktop
The auth panel CSS (brand panel gradient, form panel flex centering, feature list,
subtext color) was only defined in Login.cshtml's @section Styles — not in the
shared auth layout. EnrollPrompt used the same class names but had no styles behind
them, so the two-column layout collapsed. Added matching styles in EnrollPrompt's
own @section Styles block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:16:40 -04:00
spouliot 54f444d981 Add AI Catalog Price Check feature
Claude reviews every active catalog item against the shop's own operating costs
and returns a per-item verdict (below-cost / thin-margin / high / ok) with a
suggested price range, cost floor, and assumptions.

- New entity: CatalogPriceCheckReport (JSON blob, archived per company)
- New service: IAiCatalogPriceCheckService / AiCatalogPriceCheckService
  batches items 25 at a time to stay within model context limits
- Two new controller actions: GET AiPriceCheck (view report) + POST RunAiPriceCheck
- AiPriceCheck view: summary cards (counts by verdict), color-coded item cards
  with Edit Price link, assumptions detail, and loading spinner on submit
- AI Price Check button added to catalog Index header
- Migration AddCatalogPriceCheckReport applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:41:56 -04:00
spouliot dbe4170986 Add unit tests for 9 new services/controllers and expand existing test coverage
116 tests passing: JobPhotoService, MeasurementConversionService, PlatformSettingsService,
QuoteApprovalController, QuotePhotoService, ShopCapabilityCalculator, StorageMigrationService,
TenantContext, UsageQuotaController — plus expanded PricingCalculation, Registration, and
Subscription tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:27:30 -04:00
spouliot edce8e8c4a Move passkey enrollment prompt to post-login dedicated page
After password login, users are routed through /Passkey/EnrollPrompt
before reaching the dashboard. The page shows an Enable / Maybe later
choice using the auth layout for a clean full-screen experience.
Users who already have a passkey are skipped past instantly.

Removes the floating bottom-right card from _Layout — the dedicated
page is a better UX touchpoint (one moment, right after login, rather
than a floating card on every page).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:41:01 -04:00
spouliot 92f71f62d0 Fix iOS passkey enrollment sheet appearing on password form submit
Switch passkeySupported() from isConditionalMediationAvailable() to
isUserVerifyingPlatformAuthenticatorAvailable(). The conditional API
signals to iOS 17/18 that the page wants autofill passkey interception,
causing Safari to show its own native enrollment bottom sheet when the
password Sign In button is clicked. The platform authenticator check
simply asks if the device has biometrics, with no UI side-effects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:56:17 -04:00
spouliot c71332740e Fix passkey RPID mismatch across environments
Derive ServerDomain and Origin from the incoming HTTP request instead of
appsettings.json, so WebAuthn works on localhost, dev, and production
without any environment-specific configuration. Removed IFido2 from DI
and the Fido2 appsettings block — PasskeyController instantiates Fido2
per-request via BuildFido2().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:49:45 -04:00
spouliot edc599a1a2 Clean up TODO list and remove stale deploy_migration.sql
Completed items removed from TODO: AI catalog price check, catalog item
images, AI company lookup. deploy_migration.sql replaced by the
versioned scripts/042426_deploy_migration.sql.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:12:31 -04:00
spouliot 90a5a028ad Update docs and AI assistant for passkey biometric login
- HelpKnowledgeBase: passkey entry under USER PROFILE section with
  full how-it-works detail (setup, login flow, browser requirements,
  account-lock enforcement, per-device management)
- UserProfile help article: new Passkeys & Biometrics section between
  Two-Factor Auth and Appearance, with setup steps, login steps,
  browser compatibility note, and lost-device warning
- TOC nav link added to UserProfile article sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:08:46 -04:00
spouliot 0bb96a502a Add passkey / biometric login (WebAuthn FIDO2)
Shop floor workers can log in once with a password, enroll a passkey,
and use Face ID / Windows Hello / fingerprint for all future logins.

- UserPasskey entity + AddUserPasskeys migration (Fido2 v4.0.1)
- PasskeyController: RegisterOptions, Register, LoginOptions, Login,
  Manage, Remove endpoints
- Login page: platform-aware button (Face ID / Windows Hello / etc.)
  hidden automatically if browser doesn't support WebAuthn
- Post-login floating prompt to enroll on first use; session-dismissed
- Passkeys & Biometrics link in user dropdown menu
- Manage page: list registered devices, add new, remove individual
- passkey.js: targeted base64url conversion (only challenge + user.id
  + credential IDs) — fixes "Required parameters missing" error caused
  by blindly converting rp.id and other string fields to ArrayBuffers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:07:01 -04:00
spouliot 4f976b1332 Require auth on all work order QR codes and add top view QR
- StatusBump (GET + POST) now requires authentication; routes by job ID
  instead of anonymous ShopAccessCode GUID; records actual user name in
  status history instead of anonymous token string
- WorkOrder action generates a second "View Job" QR in the header linking
  to the authenticated Details page (for verifying specs and seeing catalog
  images on mobile); status bump QR updated to ID-based URL
- WorkOrder view: top QR added to header alongside job number; status bump
  label updated (removed "no login required" copy)
- StatusBump view: updated form routing from asp-route-token to asp-route-id
- HelpKnowledgeBase and Jobs help article updated with two-tier QR docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:27:43 -04:00
spouliot 9361cd4495 Add production Jenkins pipeline for Azure App Service deployment
Fully manual pipeline (no triggers): build/test → publish → generate
idempotent EF migration SQL (archived as artifact) → apply to Azure SQL
via sqlcmd → ZIP deploy to App Service → smoke test.

Includes jenkins/Dockerfile (adds .NET 8 SDK, Azure CLI, mssql-tools18,
dotnet-ef 8.0.11 to jenkins/jenkins:lts) and .config/dotnet-tools.json
tool manifest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 09:50:52 -04:00
spouliot 00bf8a4cd0 Add catalog item images with thumbnail preview in wizard
Each catalog item now supports one optional image (jpg/jpeg/png/gif/webp,
max 10 MB). Uploading generates a 200x200 JPEG thumbnail automatically via
SixLabors.ImageSharp. Images are stored in Azure Blob Storage under a new
catalogimages container, keyed by {companyId}/catalog/{itemId}/.

- CatalogItem entity: ImagePath + ThumbnailPath (nullable string fields)
- Migration: AddCatalogItemImages applied
- ICatalogImageService / CatalogImageService: upload, thumbnail generation,
  delete; old blobs replaced atomically on re-upload
- CatalogItemsController: Create/Edit accept optional IFormFile image;
  Image(id, thumbnail) action serves blobs with [Authorize] so wizard users
  can load thumbnails without CanManageProducts policy
- Catalog index (_CategoryNode): 40x40 thumbnail (or placeholder icon)
  left of each item name
- Details view: image card in right column with click-to-full-size link
- Create/Edit views: file picker with live preview; Edit shows current
  thumbnail with Remove checkbox
- Wizard (item-wizard.js): thumbnails in product list with hover preview
  that follows the cursor (showCatalogPreview / moveCatalogPreview);
  fixed Bootstrap d-flex !important bug that broke the filter box by
  moving flex layout to an inner wrapper div

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 09:33:59 -04:00
259 changed files with 120903 additions and 4404 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.11",
"commands": [
"dotnet-ef"
]
}
}
}
+45
View File
@@ -122,6 +122,51 @@ Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123!
- Global query filters enforce company isolation at database level
- Users have both system role (SuperAdmin) and company role (CompanyAdmin, Manager, Worker, Viewer)
## Data Access Rules (ENFORCE THESE)
> **`ApplicationDbContext` is NEVER injected into a controller.**
> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below.
> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all
> controllers at boot and throws if any non-exempt controller injects `ApplicationDbContext`.
> Full rationale and permanent exceptions list: `docs/DATA_ACCESS_ARCHITECTURE.md`
### Three tiers — use the right one:
**Tier 1 — Simple CRUD**`IUnitOfWork.EntityName` (generic `IRepository<T>`)
```csharp
var items = await _unitOfWork.CatalogItems.GetAllAsync();
await _unitOfWork.Announcements.AddAsync(entity);
await _unitOfWork.CompleteAsync();
```
**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork`
```csharp
// Include chains and domain-specific queries belong in the repository, not the controller
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
```
Typed repositories: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`,
`ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`
**Tier 3 — Aggregate/reporting queries** → injected read services
```csharp
// P&L, AR aging, cycle time, powder usage — shaped DTOs, never tracked entities
var aging = await _financialReports.GetArAgingAsync(companyId);
```
Services: `IFinancialReportService`, `IOperationalReportService`
— defined in `Core/Interfaces/Services/`, implemented in `Infrastructure/Services/`
### Permanent exceptions (ApplicationDbContext allowed — intentional, documented):
`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`,
`DataExportController`, `AccountDataExportController`, `DataPurgeController`,
`SystemInfoController`, `SystemLogsController`, `CompanyHealthController`
If you think you need a new exception, you almost certainly don't. Check the spec first.
---
## Data Access Patterns
### Common Controller Pattern
+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
+346
View File
@@ -0,0 +1,346 @@
# Data Access Architecture
## Status: Complete ✓ (2026-04-28)
This document defines the target data access architecture for Powder Coating Logix and tracks
the migration from the current mixed pattern to the clean layered pattern.
---
## The Problem
The codebase currently has ~50 controllers injecting `ApplicationDbContext` directly alongside
`IUnitOfWork`. This happened organically: the generic `Repository<T>` could not express complex
multi-level include queries, so `_context` became the escape hatch. Once injected for one complex
query, it was used for everything else in that controller too. The inconsistency compounds with
every new controller a developer writes.
For a solo developer this is manageable. For a team it creates a daily decision tax — "which
pattern do I follow?" — with no clear answer. New developers copy the nearest example, which is
usually `_context`, so the problem grows.
---
## The Rule (Short Version)
> **`ApplicationDbContext` is never injected into a controller. Ever.**
>
> All data access in controllers goes through `IUnitOfWork`.
> Complex queries that the generic `Repository<T>` cannot express live in typed repositories or
> read services — both accessible through `IUnitOfWork`.
---
## Target Architecture
```
Controllers (Presentation Layer)
├── IUnitOfWork.EntityName → IRepository<T> Simple CRUD
├── IUnitOfWork.Jobs → IJobRepository Complex domain queries
├── IUnitOfWork.Invoices → IInvoiceRepository Complex domain queries
├── IUnitOfWork.Quotes → IQuoteRepository Complex domain queries
├── IUnitOfWork.Customers → ICustomerRepository Complex domain queries
├── IUnitOfWork.Bills → IBillRepository Complex domain queries
├── IFinancialReportService Aggregate/reporting reads
└── IOperationalReportService Aggregate/reporting reads
Infrastructure Layer (the only layer that knows about ApplicationDbContext)
├── Repository<T> Generic implementation
├── JobRepository : IJobRepository Typed implementations
├── InvoiceRepository ...
├── QuoteRepository ...
├── CustomerRepository ...
├── BillRepository ...
├── FinancialReportService DbContext used directly (read-only, no tracking)
└── OperationalReportService DbContext used directly (read-only, no tracking)
```
`ApplicationDbContext` never crosses into the Presentation layer. It lives in Infrastructure and
only Infrastructure.
---
## Three Tiers of Data Access
### Tier 1 — Simple CRUD → Generic `IRepository<T>` via `IUnitOfWork`
Use for: single-entity lookups, lists, adds, soft deletes, simple filtered queries.
```csharp
// Good
var items = await _unitOfWork.CatalogItems.GetAllAsync();
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id);
await _unitOfWork.Announcements.AddAsync(entity);
await _unitOfWork.CompleteAsync();
```
Entities in this tier (generic repo is sufficient):
- Announcements, BugReports, CatalogItems, CatalogCategories, CatalogPriceCheckReports
- CompanyBlastSetups, CompanyOperatingCosts, CompanyPreferences
- ContactSubmissions, CreditMemos, CreditMemoApplications
- DashboardTips, Deposits, Equipment
- GiftCertificates, GiftCertificateRedemptions
- InventoryItems, InventoryTransactions
- JobChangeHistories, JobDailyPriorities, JobItemCoats, JobItems, JobNotes, JobPhotos
- JobStatusHistory, JobTemplates, JobTemplateItems, JobTemplateItemCoats, JobTemplateItemPrepServices
- JobTimeEntries, MaintenanceRecords, ManufacturerLookupPatterns
- NotificationLogs, NotificationTemplates
- OvenBatches, OvenBatchItems, OvenCosts
- Payments, PrepServices, PricingTiers
- PowderUsageLogs, PurchaseOrderItems
- QuoteChangeHistories, QuoteItemCoats, QuoteItems, QuoteItemPrepServices, QuotePhotos
- Refunds, ReworkRecords
- ShopWorkers, ShopWorkerRoleCosts, SubscriptionPlanConfigs
- Vendors
### Tier 2 — Complex Domain Queries → Typed Repositories
Use for: multi-level include chains, domain-specific filtered loads, queries that require
`IgnoreQueryFilters`, queries that span multiple related entities in non-trivial ways.
The typed repository interface lives in `Core/Interfaces/Repositories/`.
The implementation lives in `Infrastructure/Repositories/`.
The property is on `IUnitOfWork` — same access point as Tier 1.
```csharp
// Good — the complex include chain lives in the repository, not the controller
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
```
#### `IJobRepository`
| Method | Purpose |
|--------|---------|
| `LoadForDetailsAsync(int id)` | Full include chain for Job Details view |
| `LoadForEditAsync(int id)` | Includes needed for Job Edit form |
| `LoadForBoardAsync(int companyId, ...)` | Jobs for the kanban board with status/priority filters |
| `GetByStatusAsync(int companyId, int statusId)` | Filtered by status with customer include |
| `GetAssignedToWorkerAsync(int workerId)` | All active jobs for a worker |
| `GetOverdueAsync(int companyId)` | Jobs past due date |
#### `IInvoiceRepository`
| Method | Purpose |
|--------|---------|
| `LoadForViewAsync(int id)` | Full 8-table include chain (current `LoadInvoiceForViewAsync`) |
| `GetOverdueAsync(int companyId)` | Invoices past due date with customer info |
| `GetByPaymentTokenAsync(string token)` | Online payment portal lookup |
| `GetForJobAsync(int jobId, bool includeDeleted)` | Invoice for a given job (1:1 check) |
#### `IQuoteRepository`
| Method | Purpose |
|--------|---------|
| `LoadForViewAsync(int id)` | Full include chain for Quote Details |
| `LoadForEditAsync(int id)` | Includes needed for wizard edit |
| `GetByApprovalTokenAsync(string token)` | Customer approval portal lookup |
| `GetPendingApprovalsAsync(int companyId)` | Quotes awaiting customer approval |
#### `ICustomerRepository`
| Method | Purpose |
|--------|---------|
| `LoadForDetailsAsync(int id)` | Customer with jobs, quotes, invoices, notes summary |
| `GetWithOutstandingBalancesAsync(int companyId)` | AR summary data |
| `FindByEmailAsync(string email, int companyId)` | Duplicate check on create/edit |
#### `IBillRepository`
| Method | Purpose |
|--------|---------|
| `LoadForViewAsync(int id)` | Bill with line items, payments, vendor |
| `GetApPayablesAsync(int companyId)` | Open AP ledger with aging |
#### `IPurchaseOrderRepository`
| Method | Purpose |
|--------|---------|
| `LoadForViewAsync(int id)` | PO with line items and vendor |
| `GetByStatusAsync(int companyId, string status)` | Filtered PO list |
### Tier 3 — Aggregate/Reporting Queries → Read Services
Use for: P&L calculations, AR aging, powder usage aggregates, job cycle time, any query that
uses `GROUP BY`, window functions, or multi-table joins that return shaped result DTOs rather
than tracked entities.
These services are injected directly into controllers alongside `IUnitOfWork`. They use
`ApplicationDbContext` internally (with `.AsNoTracking()`) — that is correct and intentional,
because they live in Infrastructure.
```csharp
// Controller constructor
public ReportsController(IUnitOfWork unitOfWork, IFinancialReportService financialReports, ...)
// Usage
var aging = await _financialReports.GetArAgingAsync(companyId);
var pl = await _financialReports.GetProfitLossAsync(companyId, startDate, endDate);
```
#### `IFinancialReportService`
- `GetArAgingAsync(int companyId)` → AR aging buckets (current, 30, 60, 90+ days)
- `GetProfitLossAsync(int companyId, DateTime start, DateTime end)` → P&L summary
- `GetMonthlyRevenueAsync(int companyId, int months)` → monthly invoiced vs collected
- `GetTopOutstandingCustomersAsync(int companyId, int count)` → largest open balances
- `GetCashFlowProjectionAsync(int companyId, int days)` → forward-looking cash position
- `GetAnomaliesAsync(int companyId, int lookbackDays)` → bill/expense anomaly detection
- `GetRecentPaymentsAsync(int companyId, int count)` → recent payment activity
#### `IOperationalReportService`
- `GetJobCycleTimeAsync(int companyId, DateTime start, DateTime end)` → avg days per stage
- `GetPowderUsageAsync(int companyId, DateTime start, DateTime end)` → usage by color/vendor
- `GetWorkerProductivityAsync(int companyId, DateTime start, DateTime end)` → jobs per worker
- `GetOvenUtilizationAsync(int companyId, DateTime start, DateTime end)` → oven throughput
- `GetReworkRateAsync(int companyId, DateTime start, DateTime end)` → defect/rework trends
- `GetStatusFlowAsync(int companyId, DateTime start, DateTime end)` → job status transitions
---
## Permanent Exceptions
The following controllers are **intentionally allowed** to inject `ApplicationDbContext` directly.
This is not a smell — it is correct for their use cases. Each file has a comment explaining why.
| Controller | Reason |
|------------|--------|
| `StripeWebhookController` | Idempotency key lookup must bypass soft-delete and tenant filters |
| `WebhooksController` | Twilio raw event handling; same reasoning as Stripe |
| `PaymentController` | Stripe Connect embedded payment flow; raw session state needed |
| `RegistrationController` | PendingRegistrationSession queries bypass normal tenant scoping |
| `DataExportController` | Bulk streaming export; repository pattern adds unnecessary overhead |
| `AccountDataExportController` | Same as above |
| `DataPurgeController` | Destructive bulk operations; needs direct transaction control |
| `SystemInfoController` | Infrastructure diagnostics; queries metadata, not business data |
| `SystemLogsController` | Log table queries; not a business entity |
| `CompanyHealthController` | Cross-tenant health checks for SuperAdmin; ignores all filters |
| `PasskeyController` | WebAuthn/FIDO2 identity infrastructure; UserPasskeys is an ASP.NET Identity concern outside IUnitOfWork; anonymous login path has no tenant context |
| `AuditLogController` | Append-only audit log with `long` PK; platform infrastructure table outside the business entity graph; same reasoning as `SystemLogsController` |
| `UserActivityController` | Queries ASP.NET Identity `ApplicationUser` across all tenants with `Include(u => u.Company)`; Identity entities live outside IUnitOfWork |
| `EmailBroadcastController` | Cross-tenant fan-out querying ASP.NET Identity Users table with company joins; Identity entities live outside IUnitOfWork |
| `RevenueController` | Cross-tenant MRR/ARR metrics joining Company + SubscriptionPlanConfig; same pattern as `CompanyHealthController` |
| `StripeEventsController` | `StripeWebhookEvents` is a platform infrastructure table, not a business entity; same reasoning as `StripeWebhookController` |
| `SubscriptionManagementController` | Cross-tenant Company management with raw SQL audit log writes that bypass the tenant pipeline; platform-level concern |
| `UsageQuotaController` | Cross-tenant bulk GROUP BY quota queries; routing through IUnitOfWork would require O(n) repository round-trips |
If you think you need to add a controller to this list, you almost certainly don't. Ask first.
---
## Migration Roadmap
### Phase 1 — Foundation (no behavior change)
- [ ] Create `Core/Interfaces/Repositories/` directory
- [ ] Define `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`, `ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
- [ ] Define `IFinancialReportService`, `IOperationalReportService` in `Core/Interfaces/Services/`
- [ ] Create `Infrastructure/Repositories/` directory
- [ ] Implement all typed repositories (move include chains from controllers)
- [ ] Implement `FinancialReportService` (move aggregate queries from `ReportsController`)
- [ ] Implement `OperationalReportService`
- [ ] Extend `IUnitOfWork` with typed repository properties
- [ ] Register all new types in `Program.cs`
- [ ] Build passes, all tests green — no controller has changed yet
### Phase 2 — Complex controller migration ✓ COMPLETE (2026-04-27)
- [x] `InvoicesController``IInvoiceRepository`
- [x] `JobsController``IJobRepository`
- [x] `QuotesController``IQuoteRepository`
- [x] `CustomersController``ICustomerRepository`
- [x] `BillsController``IBillRepository`
- [x] `PurchaseOrdersController``IPurchaseOrderRepository`
- [x] `ReportsController``IFinancialReportService` + `IOperationalReportService`
### Phase 3 — Simple controller sweep ✓ COMPLETE (2026-04-28)
Remove `ApplicationDbContext` injection from all controllers not in the permanent exceptions list,
replacing with existing `IUnitOfWork` generic repository calls.
- [x] `AnnouncementsController`
- [x] `AiQuickQuoteController`
- [x] `AiUsageReportController`
- [x] `AuditLogController` → permanent exception (Identity/platform infra)
- [x] `BannedIpsController`
- [x] `BugReportController`
- [x] `CompaniesController`
- [x] `CompanySettingsController`
- [x] `CompanyUsersController`
- [x] `DashboardController`
- [x] `DashboardTipsController`
- [x] `DepositsController`
- [x] `EmailBroadcastController` → permanent exception (Identity fan-out)
- [x] `ExpensesController`
- [x] `InAppNotificationsController`
- [x] `InventoryController`
- [x] `JobsPriorityController`
- [x] `JobTemplatesController`
- [x] `NotificationLogsController`
- [x] `PasskeyController` → permanent exception (WebAuthn/FIDO2 identity infra)
- [x] `PlatformNotificationsController`
- [x] `QuoteApprovalController`
- [x] `ReleaseNotesController`
- [x] `RevenueController` → permanent exception (cross-tenant MRR/ARR)
- [x] `SetupWizardController`
- [x] `SmsConsentAuditController`
- [x] `StripeEventsController` → permanent exception (platform infra table)
- [x] `SubscriptionManagementController` → permanent exception (platform-level cross-tenant)
- [x] `UnsubscribeController`
- [x] `UsageQuotaController` → permanent exception (bulk GROUP BY)
- [x] `UserActivityController` → permanent exception (Identity entities)
- [x] `VendorsController`
### Phase 4 — Enforcement ✓ COMPLETE (2026-04-28)
- [x] `EnforceDataAccessArchitecture()` added to `Program.cs` — scans all Controller subclasses at
startup via reflection and throws `InvalidOperationException` if any non-exempt controller
has `ApplicationDbContext` in its constructor. The app cannot start with a violation.
- [x] Permanent exceptions list hardcoded in the enforcement function (18 controllers).
- [x] This document status updated to Complete.
- [ ] Update `CLAUDE.md` to mark migration complete (optional — CLAUDE.md already reflects the rule)
---
## File Locations Reference
```
src/
PowderCoating.Core/
Interfaces/
IRepository.cs existing
IUnitOfWork.cs existing — extended in Phase 1
Repositories/ NEW in Phase 1
IJobRepository.cs
IInvoiceRepository.cs
IQuoteRepository.cs
ICustomerRepository.cs
IBillRepository.cs
IPurchaseOrderRepository.cs
Services/ NEW in Phase 1
IFinancialReportService.cs
IOperationalReportService.cs
PowderCoating.Infrastructure/
Repositories/ NEW in Phase 1
UnitOfWork.cs existing — extended
Repository.cs existing
JobRepository.cs
InvoiceRepository.cs
QuoteRepository.cs
CustomerRepository.cs
BillRepository.cs
PurchaseOrderRepository.cs
Services/
FinancialReportService.cs NEW in Phase 1
OperationalReportService.cs NEW in Phase 1
NotificationService.cs existing — correct as-is
PdfService.cs existing — correct as-is
```
---
## Code Review Checklist
When reviewing a PR that touches data access:
1. Does the controller inject `ApplicationDbContext`? If yes and it's not in the permanent
exceptions list → request changes.
2. Is a complex include chain written inline in a controller action? → move to typed repository.
3. Is a GROUP BY / aggregate query inline in a controller action? → move to report service.
4. Does a new typed repository method duplicate logic already in another repository? → consolidate.
5. Are all DbContext calls in report services using `.AsNoTracking()`? → required for read services.
+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
File diff suppressed because it is too large Load Diff
@@ -15,4 +15,5 @@ public class StorageContainers
public string ReceiptImages { get; set; } = "receiptimages";
public string QuoteImages { get; set; } = "quoteimages";
public string BugReportMedia { get; set; } = "bugreportmedia";
public string CatalogImages { get; set; } = "catalogimages";
}
@@ -0,0 +1,80 @@
namespace PowderCoating.Application.DTOs.AI;
// ── Input ─────────────────────────────────────────────────────────────────────
/// <summary>Lightweight representation of a catalog item sent to Claude for analysis.</summary>
public class CatalogItemForPriceCheck
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string CategoryName { get; set; } = string.Empty;
public decimal CurrentPrice { get; set; }
public decimal? ApproximateAreaSqFt { get; set; }
public int? EstimatedMinutes { get; set; }
public bool RequiresSandblasting { get; set; }
public bool RequiresMasking { get; set; }
}
/// <summary>Operating cost summary injected into the Claude system prompt.</summary>
public class ShopOperatingCostSummary
{
public decimal LaborRatePerHour { get; set; }
public decimal OvenCostPerHour { get; set; }
public decimal SandblasterCostPerHour { get; set; }
public decimal CoatingBoothCostPerHour { get; set; }
public decimal PowderCostPerSqFt { get; set; }
public decimal ShopSuppliesRatePercent { get; set; }
public decimal MarkupOrMarginPercent { get; set; }
public string PricingMode { get; set; } = "markup"; // "markup" or "margin"
public decimal ShopMinimumCharge { get; set; }
public string? AiContextProfile { get; set; }
}
// ── Per-Item Result ───────────────────────────────────────────────────────────
/// <summary>Verdict on a single catalog item's price health.</summary>
public class CatalogItemPriceVerdict
{
public int CatalogItemId { get; set; }
public string Name { get; set; } = string.Empty;
public decimal CurrentPrice { get; set; }
/// <summary>Assumptions Claude made about size/complexity to estimate costs.</summary>
public string Assumptions { get; set; } = string.Empty;
public decimal EstimatedSqFtMin { get; set; }
public decimal EstimatedSqFtMax { get; set; }
public int EstimatedMinutesMin { get; set; }
public int EstimatedMinutesMax { get; set; }
/// <summary>Calculated cost floor using the shop's own rates.</summary>
public decimal CostFloor { get; set; }
/// <summary>ok | low | high | below-cost</summary>
public string Verdict { get; set; } = "ok";
public decimal SuggestedPriceMin { get; set; }
public decimal SuggestedPriceMax { get; set; }
/// <summary>high | medium | low</summary>
public string Confidence { get; set; } = "medium";
public string Reasoning { get; set; } = string.Empty;
}
// ── Report ────────────────────────────────────────────────────────────────────
public class CatalogPriceCheckReportDto
{
public int Id { get; set; }
public DateTime RunAt { get; set; }
public int ItemsChecked { get; set; }
public int BelowCostCount { get; set; }
public int LowMarginCount { get; set; }
public int HighPriceCount { get; set; }
public int OkCount { get; set; }
public List<CatalogItemPriceVerdict> Results { get; set; } = new();
public string OperatingCostsSummary { get; set; } = string.Empty;
}
@@ -29,6 +29,9 @@ namespace PowderCoating.Application.DTOs.Catalog
[Display(Name = "COGS Account")]
public int? CogsAccountId { get; set; }
public string? CogsAccountName { get; set; }
public string? ImagePath { get; set; }
public string? ThumbnailPath { get; set; }
}
/// <summary>
@@ -43,6 +46,7 @@ namespace PowderCoating.Application.DTOs.Catalog
public string CategoryName { get; set; } = string.Empty;
public decimal DefaultPrice { get; set; }
public bool IsActive { get; set; }
public string? ThumbnailPath { get; set; }
}
/// <summary>
@@ -158,12 +158,16 @@ public class UpdateCompanyDto
// AI feature flags
public bool AiPhotoQuotesEnabled { get; set; }
public bool AiInventoryAssistEnabled { get; set; }
public bool AiCatalogPriceCheckEnabled { get; set; }
public int? MaxAiPhotoQuotesPerMonthOverride { get; set; }
// Per-company feature overrides (null = use plan default)
public bool? OnlinePaymentsOverride { get; set; }
public bool? AccountingOverride { get; set; }
/// <summary>When true, SuperAdmin has force-disabled SMS for this company regardless of plan or company settings.</summary>
public bool SmsDisabledByAdmin { get; set; }
public string? TimeZone { get; set; }
}
@@ -35,6 +35,26 @@ namespace PowderCoating.Application.DTOs.Company
public decimal OnlinePaymentSurchargeValue { get; set; }
public bool OnlineSurchargeAcknowledged { get; set; }
public bool AllowOnlinePayments { get; set; }
// SMS gating
public bool AllowSms { get; set; }
public bool SmsEnabled { get; set; }
public bool SmsDisabledByAdmin { get; set; }
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
public bool HasCurrentSmsAgreement { get; set; }
public string SmsTermsVersion { get; set; } = string.Empty;
}
/// <summary>
/// DTO for the company admin SMS opt-in/out toggle.
/// When enabling for the first time (or after a terms version change), AgreedToTerms must
/// be true and TermsVersion must match <c>AppConstants.SmsTermsVersion</c>.
/// </summary>
public class UpdateSmsPreferencesDto
{
public bool SmsEnabled { get; set; }
public bool AgreedToTerms { get; set; }
public string? TermsVersion { get; set; }
}
/// <summary>
@@ -23,6 +23,7 @@ public class InventoryItemDto
public string? ColorFamilies { get; set; }
public bool RequiresClearCoat { get; set; }
public string? SpecPageUrl { get; set; }
public string? ImageUrl { get; set; }
public decimal QuantityOnHand { get; set; }
public string UnitOfMeasure { get; set; } = "lbs";
public decimal ReorderPoint { get; set; }
@@ -144,6 +145,10 @@ public class CreateInventoryItemDto
[Display(Name = "Product URL")]
public string? SpecPageUrl { get; set; }
[StringLength(1000, ErrorMessage = "Image URL cannot exceed 1000 characters")]
[Display(Name = "Product Image URL")]
public string? ImageUrl { get; set; }
[Range(0, 999999999, ErrorMessage = "Quantity on hand must be 0 or greater")]
[Display(Name = "Quantity on Hand")]
public decimal QuantityOnHand { get; set; }
@@ -52,6 +52,10 @@ public class JobDto
public bool RequiresCustomerApproval { get; set; }
public bool IsCustomerApproved { get; set; }
// Customer SMS opt-in — used for SMS compose modal on job details
public bool CustomerNotifyBySms { get; set; }
public string? CustomerMobilePhone { get; set; }
// Job Completion Details
public decimal? ActualTimeSpentHours { get; set; }
@@ -99,6 +103,7 @@ public class JobListDto
public string PriorityDisplayName { get; set; } = string.Empty;
public string PriorityColorClass { get; set; } = "secondary";
public bool CustomerNotifyByEmail { get; set; } = true;
public DateTime? ScheduledDate { get; set; }
public DateTime? DueDate { get; set; }
public decimal FinalPrice { get; set; }
@@ -379,6 +384,13 @@ public class CompleteJobDto
public bool SendEmailToCustomer { get; set; } = false;
}
// DTO for the Admin/Manager compose-before-send SMS endpoint
public class SendJobSmsRequest
{
public int JobId { get; set; }
public string Message { get; set; } = string.Empty;
}
// DTO for tracking actual powder usage per coat
public class JobItemCoatUsageDto
{
@@ -10,6 +10,7 @@ public class NotificationLogDto
public NotificationType NotificationType { get; set; }
public string NotificationTypeDisplay => NotificationType switch
{
NotificationType.AdminEmail => "Admin Email",
NotificationType.QuoteSent => "Quote Sent",
NotificationType.QuoteApproved => "Quote Approved",
NotificationType.JobStatusChanged => "Job Status Changed",
@@ -24,6 +24,8 @@ public class SubscriptionPlanConfigDto
public bool AllowAccounting { get; set; }
public bool AllowAiPhotoQuotes { get; set; }
public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; }
public bool IsActive { get; set; }
public int SortOrder { get; set; }
}
@@ -70,6 +72,8 @@ public class UpdateSubscriptionPlanConfigDto
public bool AllowAccounting { get; set; }
public bool AllowAiPhotoQuotes { get; set; }
public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; }
public bool IsActive { get; set; }
}
@@ -12,7 +12,7 @@ public class WizardProgressDto
public bool Completed { get; set; }
public List<int> DoneSteps { get; set; } = new();
public List<int> SkippedSteps { get; set; } = new();
public const int TotalSteps = 10;
public const int TotalSteps = 5;
public bool IsStepDone(int step) => DoneSteps.Contains(step);
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
@@ -0,0 +1,16 @@
using PowderCoating.Application.DTOs.AI;
namespace PowderCoating.Application.Interfaces;
public interface IAiCatalogPriceCheckService
{
/// <summary>
/// Analyzes the provided catalog items against the shop's operating costs and returns
/// a verdict for each item. Items are batched into groups of 25 to stay within Claude's
/// context limits. Returns null results for any item that could not be analyzed.
/// </summary>
Task<List<CatalogItemPriceVerdict>> AnalyzeAsync(
List<CatalogItemForPriceCheck> items,
ShopOperatingCostSummary costs,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,29 @@
namespace PowderCoating.Application.Interfaces;
/// <summary>Pre-aggregated AI call counts for one company across four time windows.</summary>
public record AiCompanyUsage(int CompanyId, int Today, int Last7Days, int Last30Days, int AllTime);
/// <summary>Count of AI calls for a specific feature within a company (last 30 days).</summary>
public record AiFeatureStat(int CompanyId, string Feature, int Count);
/// <summary>Bundled result returned by <see cref="IAiUsageReportService.GetReportDataAsync"/>.</summary>
public record AiUsageReportData(
List<AiCompanyUsage> UsageByCompany,
List<AiFeatureStat> FeatureStats,
Dictionary<int, int> PhotoCountsByCompany);
/// <summary>
/// Read-only service for the platform AI usage analytics report. Queries <c>AiUsageLogs</c>
/// and <c>QuotePhotos</c> (cross-tenant, non-BaseEntity) via <c>ApplicationDbContext</c>
/// directly so that <see cref="AiUsageReportController"/> does not need a direct DB context reference.
/// Implemented in Infrastructure; used as Tier-3 aggregate report service.
/// </summary>
public interface IAiUsageReportService
{
/// <summary>
/// Returns all the aggregated AI usage data needed to render the platform AI usage report:
/// per-company call counts across today / 7-day / 30-day / all-time windows,
/// feature stats for the last 30 days, and AI photo upload counts per company.
/// </summary>
Task<AiUsageReportData> GetReportDataAsync();
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Http;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Handles upload, thumbnail generation, and deletion of catalog item images stored in Azure Blob Storage.
/// All blobs are scoped under {companyId}/catalog/{itemId}/ so tenants never share a path.
/// </summary>
public interface ICatalogImageService
{
/// <summary>
/// Uploads the image, generates a 200×200 JPEG thumbnail, stores both blobs, and returns their paths.
/// On success the caller should persist the returned paths to <c>CatalogItem.ImagePath</c> and
/// <c>CatalogItem.ThumbnailPath</c>. Any previously stored blobs for the same item are deleted first.
/// </summary>
Task<(bool Success, string ImagePath, string ThumbnailPath, string ErrorMessage)> UploadAsync(
IFormFile file,
int itemId,
int companyId,
string? existingImagePath,
string? existingThumbnailPath);
/// <summary>
/// Downloads a catalog image blob and returns its raw bytes and content-type for streaming to the browser.
/// </summary>
Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(string blobPath);
/// <summary>
/// Deletes both the full-size image and thumbnail blobs. Safe to call with null paths.
/// </summary>
Task DeleteAsync(string? imagePath, string? thumbnailPath);
}
@@ -0,0 +1,23 @@
using PowderCoating.Application.DTOs.Accounting;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Read-only service for financial aggregate reports. All methods query the database
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
/// </summary>
public interface IFinancialReportService
{
/// <summary>Returns a Profit &amp; Loss report for the given company and date range.</summary>
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf);
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
/// <summary>Returns a Sales &amp; Income report for the given company and date range.</summary>
Task<SalesIncomeReportDto> GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to);
}
@@ -25,6 +25,7 @@ public class InventoryAiLookupResult
public decimal? UnitCostPerLb { get; set; } // price per lb/unit if found in search results
public string? VendorName { get; set; } // manufacturer/vendor name for dropdown matching
public string? SpecPageUrl { get; set; } // URL of the product page that was fetched
public string? ImageUrl { get; set; } // og:image or first product image found on the page
public string? Reasoning { get; set; } // brief explanation of what was found
}
@@ -11,6 +11,13 @@ public interface INotificationService
/// </summary>
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null);
/// <summary>
/// Sends the quote approval link to the customer via SMS.
/// Handles both registered customers (respects NotifyBySms) and prospects (ProspectPhone).
/// Returns (success, errorMessage) so the caller can surface the result to the user.
/// </summary>
Task<(bool Success, string? Error)> NotifyQuoteSentSmsAsync(Quote quote);
/// <summary>
/// Notify when a quote is approved by a customer.
/// </summary>
@@ -23,8 +30,23 @@ public interface INotificationService
/// <summary>
/// Notify customer when a job is completed and ready for pickup.
/// When <paramref name="suppressSms"/> is true the SMS is skipped so an admin can review
/// the message via <see cref="RenderJobCompletedSmsAsync"/> before sending manually.
/// </summary>
Task NotifyJobCompletedAsync(Job job);
Task NotifyJobCompletedAsync(Job job, bool suppressSms = false);
/// <summary>
/// Renders the job-completed SMS text for admin preview without sending it.
/// Returns null when SMS is not allowed for the company or the customer has not opted in.
/// </summary>
Task<string?> RenderJobCompletedSmsAsync(Job job);
/// <summary>
/// Sends a manually-composed SMS for a job (Admin/Manager compose-before-send path).
/// Appends "Reply STOP to opt out." if not already present, sends, and writes a NotificationLog row.
/// Returns (success, errorMessage).
/// </summary>
Task<(bool Success, string? Error)> SendJobSmsAsync(Job job, string message);
/// <summary>
/// Sends a welcome/confirmation SMS after staff records verbal SMS consent.
@@ -0,0 +1,48 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Placeholder return types for operational reports. These will be replaced with proper
/// Application DTOs as each report is migrated from ReportsController in Phase 2/3.
/// </summary>
public record JobCycleTimeReport(List<JobCycleTimeRow> Rows, int Months);
public record JobCycleTimeRow(string StatusName, double AvgDaysInStatus, int JobCount);
public record PowderUsageReport(List<PowderUsageRow> Rows, int Months);
public record PowderUsageRow(string ColorName, string VendorName, decimal TotalLbs, decimal TotalCost);
/// <summary>
/// Read-only service for operational aggregate reports. All methods query the database
/// with AsNoTracking and return pre-shaped objects — no tracked entities are returned.
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
/// </summary>
public interface IOperationalReportService
{
/// <summary>Returns average time jobs spend in each status over the given lookback period.</summary>
Task<JobCycleTimeReport> GetJobCycleTimeAsync(int companyId, int months);
/// <summary>Returns powder usage (lbs and cost) broken down by color and vendor.</summary>
Task<PowderUsageReport> GetPowderUsageAsync(int companyId, int months);
/// <summary>
/// Returns all active (non-deleted, non-voided) bills with their Vendor and non-deleted
/// Payments navigations loaded. Used by Analytics, ExpensesAp, and AI accounting actions
/// so those controllers do not need a direct ApplicationDbContext reference.
/// </summary>
Task<List<Bill>> GetActiveBillsAsync();
/// <summary>
/// Returns all non-deleted direct expenses with their ExpenseAccount navigation loaded.
/// Used by Analytics, ExpensesAp, and AI accounting actions so those controllers do not
/// need a direct ApplicationDbContext reference.
/// </summary>
Task<List<Expense>> GetAllExpensesAsync();
/// <summary>
/// Returns the full job status history log with FromStatus and ToStatus navigations
/// loaded. Used by Analytics and JobCycleTime so those actions do not need a direct
/// ApplicationDbContext reference.
/// </summary>
Task<List<JobStatusHistory>> GetAllJobStatusHistoryAsync();
}
@@ -5,6 +5,7 @@ namespace PowderCoating.Application.Interfaces;
public interface IPlatformSettingsService
{
Task<string?> GetAsync(string key);
Task<bool> GetBoolAsync(string key, bool defaultValue = false);
Task SetAsync(string key, string? value, string? updatedBy = null);
Task<IReadOnlyList<PlatformSetting>> GetAllAsync();
}
@@ -92,7 +92,10 @@ namespace PowderCoating.Application.Mappings
.ForMember(dest => dest.IsDeleted, opt => opt.Ignore())
.ForMember(dest => dest.Category, opt => opt.Ignore())
.ForMember(dest => dest.RevenueAccount, opt => opt.Ignore())
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore());
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore())
// Image paths are set by CatalogImageService after the entity is saved, not from the DTO.
.ForMember(dest => dest.ImagePath, opt => opt.Ignore())
.ForMember(dest => dest.ThumbnailPath, opt => opt.Ignore());
// UpdateCatalogItemDto -> CatalogItem
CreateMap<UpdateCatalogItemDto, CatalogItem>()
@@ -104,7 +107,9 @@ namespace PowderCoating.Application.Mappings
.ForMember(dest => dest.IsDeleted, opt => opt.Ignore())
.ForMember(dest => dest.Category, opt => opt.Ignore())
.ForMember(dest => dest.RevenueAccount, opt => opt.Ignore())
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore());
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore())
.ForMember(dest => dest.ImagePath, opt => opt.Ignore())
.ForMember(dest => dest.ThumbnailPath, opt => opt.Ignore());
// CatalogItem -> UpdateCatalogItemDto (reverse mapping for Edit)
CreateMap<CatalogItem, UpdateCatalogItemDto>();
@@ -33,6 +33,10 @@ public class InvoiceProfile : Profile
.ForMember(d => d.BalanceDue, o => o.MapFrom(s => s.BalanceDue))
.ForMember(d => d.SalesTaxAccountName, o => o.MapFrom(s => s.SalesTaxAccount != null
? $"{s.SalesTaxAccount.AccountNumber} {s.SalesTaxAccount.Name}" : null))
// These three collections are built manually in BuildInvoiceDtoAsync — no AutoMapper element map exists.
// AutoMapper 12+ throws for non-empty collections with no registered element mapping, so Ignore here.
.ForMember(d => d.Refunds, o => o.Ignore())
.ForMember(d => d.CreditApplications, o => o.Ignore())
.ForMember(d => d.GiftCertificateRedemptions, o => o.Ignore());
CreateMap<InvoiceItem, InvoiceItemDto>()
@@ -57,7 +57,13 @@ public class JobProfile : Profile
.ForMember(dest => dest.OriginalJobNumber,
opt => opt.MapFrom(src => src.OriginalJob != null ? src.OriginalJob.JobNumber : null))
.ForMember(dest => dest.IntakeCheckedByName,
opt => opt.MapFrom(src => src.IntakeCheckedBy != null ? src.IntakeCheckedBy.FullName : null));
opt => opt.MapFrom(src => src.IntakeCheckedBy != null ? src.IntakeCheckedBy.FullName : null))
.ForMember(dest => dest.CustomerNotifyBySms,
opt => opt.MapFrom(src => src.Customer != null && src.Customer.NotifyBySms))
.ForMember(dest => dest.CustomerMobilePhone,
opt => opt.MapFrom(src => src.Customer != null
? (src.Customer.MobilePhone ?? src.Customer.Phone)
: null));
// JobTimeEntry → JobTimeEntryDto
CreateMap<JobTimeEntry, JobTimeEntryDto>()
@@ -109,7 +115,9 @@ public class JobProfile : Profile
.ForMember(dest => dest.JobPriorityId, opt => opt.MapFrom(src => src.JobPriorityId))
.ForMember(dest => dest.PriorityCode, opt => opt.MapFrom(src => src.JobPriority.PriorityCode))
.ForMember(dest => dest.PriorityDisplayName, opt => opt.MapFrom(src => src.JobPriority.DisplayName))
.ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass));
.ForMember(dest => dest.PriorityColorClass, opt => opt.MapFrom(src => src.JobPriority.ColorClass))
.ForMember(dest => dest.CustomerNotifyByEmail,
opt => opt.MapFrom(src => src.Customer == null || src.Customer.NotifyByEmail));
// JobItem mappings
CreateMap<JobItem, JobItemDto>()
@@ -18,6 +18,8 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="QuestPDF" Version="2024.12.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<!-- Force newer versions of transitive packages with known CVEs -->
<PackageReference Include="System.Formats.Asn1" Version="8.0.1" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
namespace PowderCoating.Application.Services;
/// <summary>
/// Manages catalog item images in Azure Blob Storage. Each upload produces a full-size original and a
/// 200×200 JPEG thumbnail. Both blobs are stored under <c>{companyId}/catalog/{itemId}/</c> so that
/// paths are isolated per tenant and per item — existing blobs for the same item are replaced atomically
/// (delete-then-upload) to avoid orphaned files accumulating over time.
/// </summary>
public class CatalogImageService : ICatalogImageService
{
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _settings;
private readonly ILogger<CatalogImageService> _logger;
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
private const int ThumbnailSize = 200;
public CatalogImageService(
IAzureBlobStorageService blobService,
IOptions<StorageSettings> settings,
ILogger<CatalogImageService> logger)
{
_blobService = blobService;
_settings = settings.Value;
_logger = logger;
}
/// <summary>
/// Validates the upload, removes any existing blobs, stores the original, generates a 200×200 JPEG
/// thumbnail, stores the thumbnail, and returns both blob paths. The thumbnail is always stored as
/// JPEG regardless of the source format for predictable browser rendering and smaller file sizes.
/// </summary>
public async Task<(bool Success, string ImagePath, string ThumbnailPath, string ErrorMessage)> UploadAsync(
IFormFile file,
int itemId,
int companyId,
string? existingImagePath,
string? existingThumbnailPath)
{
if (file == null || file.Length == 0)
return (false, string.Empty, string.Empty, "No file provided.");
if (file.Length > MaxFileSizeBytes)
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp.");
var container = _settings.Containers.CatalogImages;
var blobId = Guid.NewGuid().ToString("N");
var imagePath = $"{companyId}/catalog/{itemId}/{blobId}{ext}";
var thumbPath = $"{companyId}/catalog/{itemId}/thumb_{blobId}.jpg";
// Delete existing blobs before uploading replacements.
await DeleteAsync(existingImagePath, existingThumbnailPath);
// Upload original.
using var originalStream = file.OpenReadStream();
var uploadResult = await _blobService.UploadAsync(container, imagePath, originalStream, file.ContentType);
if (!uploadResult.Success)
return (false, string.Empty, string.Empty, uploadResult.ErrorMessage);
// Generate and upload thumbnail.
using var thumbStream = await GenerateThumbnailAsync(file);
if (thumbStream == null)
{
// Thumbnail generation failed; clean up the original and bail out.
await _blobService.DeleteAsync(container, imagePath);
return (false, string.Empty, string.Empty, "Failed to generate thumbnail.");
}
var thumbResult = await _blobService.UploadAsync(container, thumbPath, thumbStream, "image/jpeg");
if (!thumbResult.Success)
{
await _blobService.DeleteAsync(container, imagePath);
return (false, string.Empty, string.Empty, thumbResult.ErrorMessage);
}
_logger.LogInformation("Catalog image uploaded for item {ItemId}: {ImagePath}", itemId, imagePath);
return (true, imagePath, thumbPath, string.Empty);
}
/// <inheritdoc/>
public async Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(string blobPath)
{
return await _blobService.DownloadAsync(_settings.Containers.CatalogImages, blobPath);
}
/// <inheritdoc/>
public async Task DeleteAsync(string? imagePath, string? thumbnailPath)
{
var container = _settings.Containers.CatalogImages;
if (!string.IsNullOrEmpty(imagePath))
await _blobService.DeleteAsync(container, imagePath);
if (!string.IsNullOrEmpty(thumbnailPath))
await _blobService.DeleteAsync(container, thumbnailPath);
}
/// <summary>
/// Decodes the uploaded image with ImageSharp, resizes it to fit within a 200×200 square while
/// preserving aspect ratio, and encodes the result as JPEG. Returns null if decoding fails so the
/// caller can surface a clean error without propagating an ImageSharp exception.
/// </summary>
private async Task<MemoryStream?> GenerateThumbnailAsync(IFormFile file)
{
try
{
using var inputStream = file.OpenReadStream();
using var image = await Image.LoadAsync(inputStream);
image.Mutate(ctx => ctx.Resize(new ResizeOptions
{
Size = new Size(ThumbnailSize, ThumbnailSize),
Mode = ResizeMode.Max
}));
var ms = new MemoryStream();
await image.SaveAsync(ms, new JpegEncoder { Quality = 85 });
ms.Position = 0;
return ms;
}
catch (Exception ex)
{
_logger.LogError(ex, "Thumbnail generation failed for file {FileName}", file.FileName);
return null;
}
}
}
@@ -14,6 +14,7 @@ namespace PowderCoating.Application.Services;
/// </summary>
public class FileService : IFileService
{
private const string UploadsRootFolder = "uploads";
private readonly IWebHostEnvironment _environment;
private readonly ILogger<FileService> _logger;
@@ -31,7 +32,9 @@ public class FileService : IFileService
/// Validation order: null/empty check, size limit, then extension allowlist. The original file
/// name is sanitised with <see cref="Path.GetFileName"/> to strip any directory components before
/// prepending the GUID prefix, preventing path traversal if the browser supplies a name with
/// slashes. Returns a relative path (from <c>wwwroot</c>) suitable for storing in the database.
/// slashes. The target subfolder is resolved and confined under <c>wwwroot/uploads/</c> before
/// any file system access occurs. Returns a relative path (from <c>wwwroot</c>) suitable for
/// storing in the database.
/// </summary>
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveFileAsync(
IFormFile file,
@@ -65,7 +68,11 @@ public class FileService : IFileService
// Create upload directory if it doesn't exist
// NOTE: WebRootPath is read-only on Azure Linux App Service; this service is legacy
// and should only be called for on-premises deployments. New uploads use Azure Blob.
var uploadPath = Path.Combine(_environment.WebRootPath, "uploads", subfolder);
if (!TryResolveUploadSubfolder(subfolder, out var uploadPath, out var relativeSubfolder, out var subfolderError))
{
return (false, string.Empty, subfolderError);
}
if (!Directory.Exists(uploadPath))
{
try
@@ -93,7 +100,7 @@ public class FileService : IFileService
}
// Return relative path from wwwroot
var relativePath = Path.Combine("uploads", subfolder, uniqueFileName).Replace("\\", "/");
var relativePath = Path.Combine(UploadsRootFolder, relativeSubfolder, uniqueFileName).Replace("\\", "/");
_logger.LogInformation("File saved successfully: {FilePath}", relativePath);
return (true, relativePath, string.Empty);
@@ -108,8 +115,8 @@ public class FileService : IFileService
/// <summary>
/// Deletes a file given its relative path from <c>wwwroot</c>.
/// Returns success if the file does not exist (idempotent) so that callers do not need to check
/// existence before calling. The relative path is converted to an absolute path with
/// <see cref="Path.Combine"/> rather than string concatenation to prevent directory traversal.
/// existence before calling. The relative path is normalized and must remain under
/// <c>wwwroot/uploads/</c>; paths outside that root are rejected.
/// </summary>
public async Task<(bool Success, string ErrorMessage)> DeleteFileAsync(string filePath)
{
@@ -120,7 +127,10 @@ public class FileService : IFileService
return (false, "File path is required.");
}
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError))
{
return (false, pathError);
}
if (!File.Exists(fullPath))
{
@@ -142,8 +152,8 @@ public class FileService : IFileService
/// <summary>
/// Reads a file from disk and returns its raw bytes along with a derived MIME content type.
/// Intended for serving files that are stored outside <c>wwwroot</c> (or otherwise not directly
/// accessible via the static-files middleware) so controllers can stream them as file responses.
/// Intended for serving files that are stored under the legacy <c>wwwroot/uploads/</c> path but
/// are otherwise not directly exposed through the static-files middleware.
/// </summary>
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetFileAsync(string filePath)
{
@@ -154,7 +164,10 @@ public class FileService : IFileService
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
}
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError))
{
return (false, Array.Empty<byte>(), string.Empty, pathError);
}
if (!File.Exists(fullPath))
{
@@ -175,7 +188,7 @@ public class FileService : IFileService
}
/// <summary>
/// Checks whether a file exists at the given <c>wwwroot</c>-relative path without reading it.
/// Checks whether a file exists at the given <c>wwwroot/uploads/</c>-relative path without reading it.
/// Used by views and controllers to conditionally show download links only when the file is present.
/// </summary>
public bool FileExists(string filePath)
@@ -185,7 +198,11 @@ public class FileService : IFileService
return false;
}
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out _))
{
return false;
}
return File.Exists(fullPath);
}
@@ -212,4 +229,96 @@ public class FileService : IFileService
_ => "application/octet-stream"
};
}
private bool TryResolveUploadSubfolder(
string subfolder,
out string uploadPath,
out string relativeSubfolder,
out string errorMessage)
{
uploadPath = string.Empty;
relativeSubfolder = string.Empty;
errorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(subfolder))
{
errorMessage = "Upload subfolder is required.";
return false;
}
if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage))
{
return false;
}
var normalizedSubfolder = subfolder.Replace('\\', '/').Trim('/');
var resolvedPath = Path.GetFullPath(
Path.Combine(uploadsRoot, normalizedSubfolder.Replace('/', Path.DirectorySeparatorChar)));
if (!IsWithinDirectory(resolvedPath, uploadsRoot))
{
errorMessage = "Invalid upload subfolder.";
_logger.LogWarning("Rejected upload subfolder outside uploads root: {Subfolder}", subfolder);
return false;
}
relativeSubfolder = Path.GetRelativePath(uploadsRoot, resolvedPath).Replace("\\", "/");
uploadPath = resolvedPath;
return true;
}
private bool TryResolveLegacyUploadPath(string filePath, out string fullPath, out string errorMessage)
{
fullPath = string.Empty;
errorMessage = string.Empty;
if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage))
{
return false;
}
var normalizedRelativePath = filePath.Replace('\\', '/').TrimStart('/');
if (!normalizedRelativePath.StartsWith($"{UploadsRootFolder}/", StringComparison.OrdinalIgnoreCase))
{
errorMessage = "Invalid file path.";
_logger.LogWarning("Rejected legacy file path outside uploads root: {FilePath}", filePath);
return false;
}
var resolvedPath = Path.GetFullPath(
Path.Combine(_environment.WebRootPath, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)));
if (!IsWithinDirectory(resolvedPath, uploadsRoot))
{
errorMessage = "Invalid file path.";
_logger.LogWarning("Rejected path traversal attempt for legacy file path: {FilePath}", filePath);
return false;
}
fullPath = resolvedPath;
return true;
}
private bool TryGetUploadsRootPath(out string uploadsRoot, out string errorMessage)
{
uploadsRoot = string.Empty;
errorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(_environment.WebRootPath))
{
errorMessage = "File storage is not available in this environment.";
_logger.LogWarning("WebRootPath is not configured for the legacy file service.");
return false;
}
uploadsRoot = Path.GetFullPath(Path.Combine(_environment.WebRootPath, UploadsRootFolder));
return true;
}
private static bool IsWithinDirectory(string candidatePath, string rootPath)
{
var normalizedRoot = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
return candidatePath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase);
}
}
@@ -120,8 +120,11 @@ public class StorageMigrationService : IStorageMigrationService
var contentType = GetContentType(Path.GetExtension(fullPath).ToLowerInvariant());
await using var stream = File.OpenRead(fullPath);
var uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType);
(bool Success, string ErrorMessage) uploadResult;
await using (var stream = File.OpenRead(fullPath))
{
uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType);
}
if (!uploadResult.Success)
{
@@ -61,6 +61,9 @@ public class ApplicationUser : IdentityUser
public DateTime? UpdatedAt { get; set; }
public DateTime? LastLoginDate { get; set; }
// Passkey enrollment prompt
public bool PasskeyPromptDismissed { get; set; } = false;
// Ban
public bool IsBanned { get; set; } = false;
public DateTime? BannedAt { get; set; }
@@ -97,5 +97,19 @@ namespace PowderCoating.Core.Entities
public virtual Account? RevenueAccount { get; set; }
public virtual Account? CogsAccount { get; set; }
// ── Images ────────────────────────────────────────────────────────────
/// <summary>
/// Blob path of the full-size uploaded image, relative to the catalogimages container.
/// Null when no image has been uploaded.
/// </summary>
public string? ImagePath { get; set; }
/// <summary>
/// Blob path of the 200×200 thumbnail generated on upload.
/// Null when no image has been uploaded.
/// </summary>
public string? ThumbnailPath { get; set; }
}
}
@@ -0,0 +1,20 @@
namespace PowderCoating.Core.Entities
{
/// <summary>
/// Stores the result of the most recent AI catalog price check run for a company.
/// ResultsJson holds the full per-item verdict array serialized as JSON, avoiding the
/// need for a wide per-item table while still persisting the report across sessions.
/// Only one report is kept per company — each new run overwrites the previous one.
/// </summary>
public class CatalogPriceCheckReport : BaseEntity
{
public DateTime RunAt { get; set; }
public int ItemsChecked { get; set; }
/// <summary>JSON-serialized List&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).
@@ -86,6 +88,19 @@ public class Company : BaseEntity
/// </summary>
public bool? AccountingOverride { get; set; }
/// <summary>
/// Company admin opt-in for SMS notifications. Defaults to false — company admin must
/// explicitly accept the SMS terms of service before enabling. Has no effect if the plan
/// does not allow SMS or if SmsDisabledByAdmin is true.
/// </summary>
public bool SmsEnabled { get; set; } = false;
/// <summary>
/// SuperAdmin force-disable for this company's SMS. When true, no SMS is sent regardless
/// of plan or company settings. Use when a company is abusing SMS or requests a full opt-out.
/// </summary>
public bool SmsDisabledByAdmin { get; set; } = false;
// Email marketing opt-out (CAN-SPAM compliance for platform broadcast emails)
public bool MarketingEmailOptOut { get; set; } = false;
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
@@ -86,6 +86,22 @@ public class CompanyPreferences : BaseEntity
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
public string? QbMigrationStateJson { get; set; }
// Guided activation / first-workflow onboarding
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
public string? OnboardingPath { get; set; }
/// <summary>True once the company completes its first guided real workflow.</summary>
public bool FirstWorkflowCompleted { get; set; } = false;
/// <summary>UTC timestamp of when the first guided workflow was completed.</summary>
public DateTime? FirstWorkflowCompletedAt { get; set; }
/// <summary>UTC timestamp of the company's first quote creation.</summary>
public DateTime? FirstQuoteCreatedAt { get; set; }
/// <summary>UTC timestamp of the company's first job creation.</summary>
public DateTime? FirstJobCreatedAt { get; set; }
/// <summary>UTC timestamp of the company's first invoice creation.</summary>
public DateTime? FirstInvoiceCreatedAt { get; set; }
/// <summary>UTC timestamp of when the company dismissed guided activation without completing it.</summary>
public DateTime? GuidedActivationDismissedAt { get; set; }
// Navigation
public virtual Company Company { get; set; } = null!;
}
@@ -0,0 +1,33 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Immutable audit record of a company admin accepting the SMS terms of service.
/// One record is written each time a user accepts (including re-accepts after a terms update).
/// The most recent record whose <see cref="TermsVersion"/> matches
/// <c>AppConstants.SmsTermsVersion</c> is the authoritative acceptance for that company.
/// Never soft-deleted — this is a legal audit trail.
/// </summary>
public class CompanySmsAgreement : BaseEntity
{
/// <summary>The Identity user ID of the admin who clicked "I Agree".</summary>
public string AgreedByUserId { get; set; } = string.Empty;
/// <summary>Display name snapshot of the user at the time of agreement (for audit readability after user changes).</summary>
public string AgreedByUserName { get; set; } = string.Empty;
/// <summary>UTC timestamp of acceptance.</summary>
public DateTime AgreedAt { get; set; }
/// <summary>Client IP address at the time of acceptance. Stored for legal/fraud purposes.</summary>
public string? IpAddress { get; set; }
/// <summary>HTTP User-Agent header at the time of acceptance.</summary>
public string? UserAgent { get; set; }
/// <summary>
/// The version of the SMS terms that was accepted (matches <c>AppConstants.SmsTermsVersion</c>
/// at the moment of acceptance). When the platform bumps this version, existing records become
/// stale and the company must re-accept.
/// </summary>
public string TermsVersion { get; set; } = string.Empty;
}
@@ -26,6 +26,7 @@ public class InventoryItem : BaseEntity
public string? ColorFamilies { get; set; } // Comma-separated primary color families e.g. "Green,Blue"
public bool RequiresClearCoat { get; set; } // True if this powder requires a clear coat topcoat
public string? SpecPageUrl { get; set; } // Link to manufacturer's product/spec page
public string? ImageUrl { get; set; } // Product image URL (sourced from og:image on AI lookup)
// Sample Panel Tracking (coating category items only)
public bool HasSamplePanel { get; set; } = false;
+4
View File
@@ -55,6 +55,10 @@ public class Job : BaseEntity
public int? IntakePartCount { get; set; }
public string? IntakeCheckedByUserId { get; set; }
// Quote snapshot — UpdatedAt of the source quote at the moment this job was created from it.
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
// Rework tracking
public bool IsReworkJob { get; set; }
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
@@ -14,4 +14,5 @@ public static class PlatformSettingKeys
public const string StripeWebhookRetentionDays = "StripeWebhookRetentionDays";
public const string MaxTenants = "MaxTenants";
public const string SmsEnabled = "SmsEnabled";
public const string AiCatalogPriceCheckEnabled = "AiCatalogPriceCheckEnabled";
}
@@ -46,6 +46,12 @@ public class SubscriptionPlanConfig : BaseEntity
/// <summary>When true, companies on this plan can use the AI Inventory Assist lookup feature.</summary>
public bool AllowAiInventoryAssist { get; set; } = false;
/// <summary>When true, companies on this plan can run the AI Catalog Price Check (Enterprise only).</summary>
public bool AllowAiCatalogPriceCheck { get; set; } = false;
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
public bool AllowSms { get; set; } = false;
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,39 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Stores a WebAuthn public-key credential (passkey) registered by an application user.
/// One row per device per user. Does not inherit BaseEntity — passkeys are identity
/// credentials, not business-domain records, and require no soft-delete or company-scoped
/// global query filter (the Login flow queries across tenants by credentialId before auth).
/// </summary>
public class UserPasskey
{
public int Id { get; set; }
/// <summary>FK to AspNetUsers.Id (GUID string).</summary>
public string UserId { get; set; } = default!;
/// <summary>Stored for display/management queries. NOT used as a query filter.</summary>
public int CompanyId { get; set; }
/// <summary>WebAuthn credential ID — unique identifier for this passkey.</summary>
public byte[] CredentialId { get; set; } = default!;
/// <summary>COSE-encoded public key from the authenticator.</summary>
public byte[] PublicKey { get; set; } = default!;
/// <summary>Opaque user handle sent by the authenticator during login.</summary>
public byte[] UserHandle { get; set; } = default!;
/// <summary>
/// Monotonically increasing counter used to detect cloned authenticators.
/// Stored as long to avoid SQL Server uint mapping issues; Fido2NetLib uses uint.
/// </summary>
public long SignCount { get; set; }
/// <summary>User-supplied or browser-provided friendly name, e.g. "Scott's iPhone".</summary>
public string? DeviceFriendlyName { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastUsedAt { get; set; }
}
@@ -17,5 +17,6 @@ public enum NotificationType
SubscriptionExpiryReminder = 10,
SubscriptionExpired = 11,
SmsInboundStop = 12,
SmsInboundHelp = 13
SmsInboundHelp = 13,
AdminEmail = 14
}
@@ -0,0 +1,29 @@
using System.Linq.Expressions;
namespace PowderCoating.Core.Interfaces;
/// <summary>
/// Lightweight repository interface for platform-level entities that do not inherit
/// <see cref="PowderCoating.Core.Entities.BaseEntity"/> (e.g. Announcement, BannedIp,
/// DashboardTip, ReleaseNote). These entities have no CompanyId, no IsDeleted, and no
/// soft-delete semantics — so the full IRepository&lt;T&gt; contract (SoftDeleteAsync,
/// ignoreQueryFilters) does not apply.
/// </summary>
/// <typeparam name="T">Any EF-mapped class (does not need to inherit BaseEntity).</typeparam>
public interface IPlainRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);
Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate);
Task<bool> AnyAsync(Expression<Func<T, bool>> predicate);
Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null);
Task<T> AddAsync(T entity);
Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> entities);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
Task DeleteAsync(int id);
}
@@ -28,6 +28,8 @@ public interface ISubscriptionService
Task<bool> CanUseAiPhotoQuoteAsync(int companyId);
/// <summary>Returns (used this month, monthly max). Max = -1 means unlimited.</summary>
Task<(int Used, int Max)> GetAiPhotoQuoteUsageAsync(int companyId);
/// <summary>Returns true if the AI Catalog Price Check is enabled for this company (plan gate + per-company flag).</summary>
Task<bool> CanUseAiCatalogPriceCheckAsync(int companyId);
/// <summary>
/// Returns days until expiry (negative = days past expiry). Returns null if no end date set.
@@ -1,4 +1,5 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
namespace PowderCoating.Core.Interfaces;
@@ -8,34 +9,37 @@ public interface IUnitOfWork : IDisposable
IRepository<Company> Companies { get; }
IRepository<CompanyOperatingCosts> CompanyOperatingCosts { get; }
IRepository<CompanyPreferences> CompanyPreferences { get; }
IRepository<CompanySmsAgreement> CompanySmsAgreements { get; }
// AI Predictions
IRepository<AiItemPrediction> AiItemPredictions { get; }
// Powder Insights
IRepository<PowderUsageLog> PowderUsageLogs { get; }
IPowderUsageLogRepository PowderUsageLogs { get; }
// Core entities
IRepository<Customer> Customers { get; }
IRepository<Job> Jobs { get; }
// Core entities — typed repositories for complex domains
ICustomerRepository Customers { get; }
IJobRepository Jobs { get; }
IRepository<JobDailyPriority> JobDailyPriorities { get; }
IRepository<JobItem> JobItems { get; }
IRepository<JobItemCoat> JobItemCoats { get; }
IJobItemCoatRepository JobItemCoats { get; }
IRepository<JobItemPrepService> JobItemPrepServices { get; }
IRepository<JobChangeHistory> JobChangeHistories { get; }
IRepository<Quote> Quotes { get; }
IRepository<JobPrepService> JobPrepServices { get; }
IQuoteRepository Quotes { get; }
IRepository<QuotePhoto> QuotePhotos { get; }
IRepository<QuoteItem> QuoteItems { get; }
IRepository<QuoteItemCoat> QuoteItemCoats { get; }
IRepository<QuoteItemPrepService> QuoteItemPrepServices { get; }
IRepository<QuoteChangeHistory> QuoteChangeHistories { get; }
IRepository<InventoryItem> InventoryItems { get; }
IRepository<InventoryTransaction> InventoryTransactions { get; }
IInventoryTransactionRepository InventoryTransactions { get; }
IRepository<Equipment> Equipment { get; }
IRepository<OvenCost> OvenCosts { get; }
IRepository<CompanyBlastSetup> BlastSetups { get; }
IRepository<MaintenanceRecord> MaintenanceRecords { get; }
IRepository<Vendor> Vendors { get; }
IRepository<JobPhoto> JobPhotos { get; }
IJobPhotoRepository JobPhotos { get; }
IRepository<JobNote> JobNotes { get; }
IRepository<CustomerNote> CustomerNotes { get; }
IRepository<JobStatusHistory> JobStatusHistory { get; }
@@ -63,43 +67,52 @@ public interface IUnitOfWork : IDisposable
// Product Catalog
IRepository<CatalogCategory> CatalogCategories { get; }
IRepository<CatalogItem> CatalogItems { get; }
IRepository<CatalogPriceCheckReport> CatalogPriceCheckReports { get; }
// Oven Scheduling
IRepository<OvenBatch> OvenBatches { get; }
IRepository<OvenBatchItem> OvenBatchItems { get; }
// Invoices, Payments & Deposits
IRepository<Invoice> Invoices { get; }
// Invoices, Payments & Deposits — typed repository for complex include chains
IInvoiceRepository Invoices { get; }
IRepository<InvoiceItem> InvoiceItems { get; }
IRepository<Payment> Payments { get; }
IRepository<Deposit> Deposits { get; }
// Purchase Orders
IRepository<PurchaseOrder> PurchaseOrders { get; }
// Purchase Orders — typed repository for paged/filtered list and detail load
IPurchaseOrderRepository PurchaseOrders { get; }
IRepository<PurchaseOrderItem> PurchaseOrderItems { get; }
// Expense Tracking / Accounts Payable
// Expense Tracking / Accounts Payable — typed repository for Bills
IRepository<Account> Accounts { get; }
IRepository<Bill> Bills { get; }
IBillRepository Bills { get; }
IRepository<BillLineItem> BillLineItems { get; }
IRepository<BillPayment> BillPayments { get; }
IRepository<Expense> Expenses { get; }
// Notifications
IRepository<NotificationLog> NotificationLogs { get; }
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
INotificationLogRepository NotificationLogs { get; }
IRepository<NotificationTemplate> NotificationTemplates { get; }
// Subscription
IRepository<SubscriptionPlanConfig> SubscriptionPlanConfigs { get; }
// Job Templates
IRepository<JobTemplate> JobTemplates { get; }
IJobTemplateRepository JobTemplates { get; }
IRepository<JobTemplateItem> JobTemplateItems { get; }
IRepository<JobTemplateItemCoat> JobTemplateItemCoats { get; }
IRepository<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; }
// Platform content (SuperAdmin-managed, no tenant filter, no soft delete)
IPlainRepository<Announcement> Announcements { get; }
IPlainRepository<BannedIp> BannedIps { get; }
IPlainRepository<DashboardTip> DashboardTips { get; }
IRepository<InAppNotification> InAppNotifications { get; }
IPlainRepository<ReleaseNote> ReleaseNotes { get; }
// Bug Reports
IRepository<BugReport> BugReports { get; }
IRepository<BugReportAttachment> BugReportAttachments { get; }
// Contact Us
IRepository<ContactSubmission> ContactSubmissions { get; }
@@ -0,0 +1,49 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Bill"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IBillRepository : IRepository<Bill>
{
/// <summary>
/// Loads a single bill with the full include chain required by the Details view: Vendor,
/// APAccount, LineItems (filtered to non-deleted) with Account and Job navigations, and
/// Payments (filtered to non-deleted) with BankAccount. Returns null if not found.
/// </summary>
Task<Bill?> LoadForViewAsync(int id);
/// <summary>
/// Loads a single bill with only its line items for the Edit form. Excludes payment
/// navigations since those are read-only after the bill is opened.
/// </summary>
Task<Bill?> LoadForEditAsync(int id);
/// <summary>
/// Returns all bills for the Index/AP ledger view filtered by status and/or search term.
/// Includes Vendor so the list row can display vendor name without a second round trip.
/// LineItems are included for the search-in-description condition only.
/// </summary>
Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount);
/// <summary>
/// Returns the last bill number with the given prefix (including soft-deleted records) for
/// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted.
/// </summary>
Task<string?> GetLastBillNumberAsync(string prefix);
/// <summary>
/// Returns the last payment number with the given prefix (including soft-deleted records)
/// for sequential payment reference generation.
/// </summary>
Task<string?> GetLastPaymentNumberAsync(string prefix);
/// <summary>
/// Returns all non-deleted bills whose <c>BillDate</c> falls within [<paramref name="start"/>,
/// <paramref name="end"/>], with Vendor, LineItems → Account, and Payments loaded.
/// Used by the accounting data export to produce QuickBooks IIF / CSV files.
/// </summary>
Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end);
}
@@ -0,0 +1,22 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Customer"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface ICustomerRepository : IRepository<Customer>
{
/// <summary>
/// Loads a single customer with the navigations needed by the Details view: PricingTier,
/// and recent CustomerNotes ordered newest-first. Returns null if not found or soft-deleted.
/// </summary>
Task<Customer?> LoadForDetailsAsync(int id);
/// <summary>
/// Finds a customer by email address within the current tenant. Used for duplicate-email
/// validation on create and edit. Returns null if no match is found.
/// </summary>
Task<Customer?> FindByEmailAsync(string email);
}
@@ -0,0 +1,22 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="InventoryTransaction"/> that adds a dynamic-filter
/// query for the Inventory Ledger view on top of the generic CRUD interface.
/// </summary>
public interface IInventoryTransactionRepository : IRepository<InventoryTransaction>
{
/// <summary>
/// Returns up to 500 non-deleted inventory transactions matching the supplied filters,
/// ordered newest-first, with InventoryItem, PurchaseOrder, and Job navigations loaded.
/// Null parameter values are treated as "no filter" for that dimension.
/// </summary>
Task<List<InventoryTransaction>> GetForLedgerAsync(
int? itemId,
DateTime? from,
DateTime? to,
InventoryTransactionType? type);
}
@@ -0,0 +1,52 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Invoice"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IInvoiceRepository : IRepository<Invoice>
{
/// <summary>
/// Loads a single invoice with the full eight-table include chain required by the Details
/// view and PDF generation: Customer, Job, PreparedBy, SalesTaxAccount, InvoiceItems with
/// RevenueAccount and GeneratedGiftCertificate, Payments with RecordedBy and DepositAccount,
/// Refunds with IssuedBy, CreditApplications with CreditMemo, and GiftCertificateRedemptions.
/// Filtered includes exclude soft-deleted children. Returns null if not found or soft-deleted.
/// </summary>
Task<Invoice?> LoadForViewAsync(int id);
/// <summary>
/// Returns the invoice linked to a job, or null if none exists. Pass
/// <paramref name="includeDeleted"/> = true to also surface soft-deleted invoices (used by
/// the 1:1 uniqueness guard that prevents duplicate invoices for the same job).
/// </summary>
Task<Invoice?> GetForJobAsync(int jobId, bool includeDeleted = false);
/// <summary>
/// Looks up an invoice by its online-payment token. Ignores query filters so the payment
/// portal can load the invoice even when the anonymous request has no tenant context.
/// Returns null if the token does not match any invoice.
/// </summary>
Task<Invoice?> GetByPaymentTokenAsync(string token);
/// <summary>
/// Returns the last invoice number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted invoices) for sequential number generation.
/// </summary>
Task<string?> GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix);
/// <summary>
/// Returns all non-deleted invoices that have at least one online payment for the given company
/// and date window, with Customer navigation loaded. Used by the Online Payments reconciliation view.
/// </summary>
Task<List<Invoice>> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to);
/// <summary>
/// Returns all non-deleted CreditDebitCard refunds for the given company and date window,
/// with Invoice→Customer navigation loaded. Used by the Online Payments reconciliation view.
/// </summary>
Task<List<Refund>> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to);
}
@@ -0,0 +1,40 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="JobItemCoat"/> that adds ThenInclude-based load methods the
/// generic <see cref="IRepository{T}"/> cannot express. Used by DashboardController for powder
/// order marking, powder receipt, and custom powder inventory creation.
/// </summary>
public interface IJobItemCoatRepository : IRepository<JobItemCoat>
{
/// <summary>
/// Loads a coat with the full vendor + job chain needed by the <c>MarkPowderOrdered</c> action:
/// <c>JobItem → Job → Customer</c>, <c>InventoryItem → PrimaryVendor</c>, and direct
/// <c>Vendor</c>. Returns <see langword="null"/> if not found.
/// </summary>
Task<JobItemCoat?> LoadForOrderMarkingAsync(int id);
/// <summary>
/// Loads a coat with only <c>InventoryItem</c> included — used by <c>ReceivePowder</c> for
/// the initial stock update. Returns <see langword="null"/> if not found.
/// </summary>
Task<JobItemCoat?> LoadWithInventoryAsync(int id);
/// <summary>
/// Loads a coat with <c>JobItem → Job</c> included — used by <c>ReceivePowder</c> to verify
/// company ownership when the initial load did not include the job chain. EF Core identity-map
/// fixup propagates <c>JobItem</c> back to any previously tracked instance of the same coat.
/// Returns <see langword="null"/> if not found.
/// </summary>
Task<JobItemCoat?> LoadWithJobChainAsync(int id);
/// <summary>
/// Returns all non-deleted coats that have no linked inventory item and belong to
/// <paramref name="companyId"/>, excluding <paramref name="excludeCoatId"/>. Used by
/// <c>AddCustomPowderToInventory</c> to link sibling coats to the newly created item.
/// Entities are tracked so that <c>InventoryItemId</c> mutations are saved via UnitOfWork.
/// </summary>
Task<List<JobItemCoat>> GetCandidateCoatsForLinkingAsync(int excludeCoatId, int companyId);
}
@@ -0,0 +1,28 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="JobPhoto"/> that adds inventory-specific photo lookup
/// queries on top of the generic CRUD interface. These queries require multi-level
/// ThenInclude chains and dynamic filtering that the generic <see cref="IRepository{T}"/>
/// cannot express.
/// </summary>
public interface IJobPhotoRepository : IRepository<JobPhoto>
{
/// <summary>
/// Returns all non-deleted, tagged job photos whose <c>Tags</c> field contains
/// <paramref name="colorName"/> or <paramref name="itemName"/> (SQL LIKE), ordered
/// newest-first, with Job → Customer navigation loaded. The caller performs an
/// exact-token match in memory to reject false positives before paginating.
/// </summary>
Task<List<JobPhoto>> GetTaggedPhotosAsync(string? colorName, string? itemName);
/// <summary>
/// Returns all non-deleted job photos from jobs that use a specific inventory item
/// in any coat, matched via <c>JobItemCoat.InventoryItemId</c>. Loads
/// Job → Customer and Job → JobItems → Coats navigations. Used by the
/// Photos by Powder panel on the inventory item detail page.
/// </summary>
Task<List<JobPhoto>> GetPhotosByPowderItemAsync(int inventoryItemId);
}
@@ -0,0 +1,88 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="Job"/> that extends the generic CRUD interface with
/// domain-specific queries that require multi-level include chains the generic
/// <see cref="IRepository{T}"/> cannot express.
/// </summary>
public interface IJobRepository : IRepository<Job>
{
/// <summary>
/// Loads all active jobs with the minimal set of navigations needed to render the Kanban
/// board columns (Customer name, status, priority, assigned user, due date). Uses
/// AsNoTracking for read performance.
/// </summary>
Task<List<Job>> GetBoardJobsAsync();
/// <summary>
/// Loads a single job with the full include chain required by the Details view: Customer,
/// JobStatus, JobPriority, AssignedUser, Quote, OvenCost, OriginalJob, IntakeCheckedBy,
/// and all JobItems with their Coats (InventoryItem + Vendor) and PrepServices.
/// Also loads JobPrepServices (job-level prep) separately. Returns null if not found.
/// </summary>
Task<Job?> LoadForDetailsAsync(int id);
/// <summary>
/// Loads a single job with the include chain required by the Edit form: same as
/// <see cref="LoadForDetailsAsync"/> but without the read-only audit navigations, and
/// with tracking enabled so changes can be saved.
/// </summary>
Task<Job?> LoadForEditAsync(int id);
/// <summary>
/// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump).
/// Includes only JobStatus. Returns null if not found or soft-deleted.
/// </summary>
Task<Job?> LoadForStatusChangeAsync(int id);
/// <summary>
/// Returns the change history for a job, ordered newest-first, with ChangedBy navigation
/// loaded. Used by the Details view changelog tab.
/// </summary>
Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId);
/// <summary>
/// Returns the last job number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted jobs) for sequential number generation.
/// </summary>
Task<string?> GetLastJobNumberByPrefixAsync(int companyId, string prefix);
/// <summary>
/// Looks up a job created from <paramref name="quoteId"/> that may be incomplete (items not
/// saved). Ignores query filters so it catches soft-deleted leftover rows from a previous
/// failed conversion attempt. Used for orphan cleanup before retrying conversion.
/// </summary>
Task<Job?> GetOrphanedConversionJobAsync(int quoteId, int companyId);
/// <summary>
/// Loads all jobs scheduled for <paramref name="date"/> that are not in a terminal status,
/// with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats) navigations.
/// Optionally filtered to a single worker when <paramref name="userId"/> is supplied.
/// Used by the ShopDisplay (TV board) action.
/// </summary>
Task<List<Job>> GetScheduledJobsForDateAsync(DateTime date, string? userId = null);
/// <summary>
/// Loads all active (non-terminal, non-hold, non-cancelled) jobs for a company's shop mobile
/// view, with Customer, JobStatus, JobPriority, AssignedUser, and JobItems (with Coats).
/// Optionally filtered to a single worker when <paramref name="workerId"/> is supplied.
/// </summary>
Task<List<Job>> GetActiveJobsForMobileAsync(int companyId, string? workerId = null);
/// <summary>
/// Loads a single job with the navigations required by the costing breakdown endpoint:
/// OvenCost, Invoice, JobItems (with Coats), and TimeEntries (with Worker).
/// Scoped to <paramref name="companyId"/> as an extra safety check.
/// Returns null if not found.
/// </summary>
Task<Job?> LoadForCostingAsync(int jobId, int companyId);
/// <summary>
/// Loads a single job with JobItems → Coats and JobItems → PrepServices for deep-copying
/// into a new <see cref="JobTemplate"/> via <c>SaveJobAsTemplate</c>.
/// Returns null if not found or soft-deleted.
/// </summary>
Task<Job?> LoadForTemplateSnapshotAsync(int jobId);
}
@@ -0,0 +1,25 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="JobTemplate"/> that extends the generic CRUD interface with
/// domain-specific queries requiring multi-level include chains the generic
/// <see cref="IRepository{T}"/> cannot express.
/// </summary>
public interface IJobTemplateRepository : IRepository<JobTemplate>
{
/// <summary>
/// Loads a single template with the full include chain for the Details view:
/// Customer, Items (with Coats → InventoryItem and PrepServices → PrepService).
/// Returns null if not found or soft-deleted.
/// </summary>
Task<JobTemplate?> LoadForDetailsAsync(int id);
/// <summary>
/// Returns all active, non-deleted templates for the current company with Customer,
/// Items → Coats, and Items → PrepServices → PrepService loaded. Used by the
/// GetTemplatesJson AJAX endpoint to hydrate the job creation wizard.
/// </summary>
Task<List<JobTemplate>> GetAllActiveWithFullIncludesAsync();
}
@@ -0,0 +1,46 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="NotificationLog"/> that adds IgnoreQueryFilters-based lookups
/// by entity FK (InvoiceId, QuoteId, JobId) on top of the generic CRUD interface.
/// All methods bypass soft-delete and tenant filters so notification history is always visible
/// regardless of whether the linked entity has been soft-deleted.
/// </summary>
public interface INotificationLogRepository : IRepository<NotificationLog>
{
/// <summary>Returns the most recent notification log entry for the given invoice, or null.</summary>
Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId);
/// <summary>Returns all notification log entries for the given invoice, newest-first.</summary>
Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId);
/// <summary>Returns the most recent notification log entry for the given quote, or null.</summary>
Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId);
/// <summary>Returns all notification log entries for the given quote, newest-first.</summary>
Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId);
/// <summary>Returns the most recent notification log entry for the given job, or null.</summary>
Task<NotificationLog?> GetLatestForJobAsync(int jobId);
/// <summary>Returns all notification log entries for the given job, newest-first.</summary>
Task<List<NotificationLog>> GetAllForJobAsync(int jobId);
/// <summary>
/// Returns a paginated, filtered, and sorted page of notification log entries with Customer,
/// Job, and Quote navigations loaded. All filter parameters are optional — omit to include all.
/// Used by the company-scoped notification log index view.
/// </summary>
Task<(List<NotificationLog> Items, int TotalCount)> GetPagedFilteredAsync(
int pageNumber, int pageSize,
string? searchTerm = null,
NotificationChannel? channel = null,
NotificationStatus? status = null,
NotificationType? type = null,
int? jobId = null,
string sortColumn = "SentAt",
string sortDirection = "desc");
}
@@ -0,0 +1,17 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="PowderUsageLog"/> that adds a dynamic-filter
/// query for the Inventory Ledger usage tab on top of the generic CRUD interface.
/// </summary>
public interface IPowderUsageLogRepository : IRepository<PowderUsageLog>
{
/// <summary>
/// Returns up to 500 non-deleted powder usage logs matching the supplied filters,
/// ordered newest-first, with Job → Customer, InventoryItem, and JobItemCoat
/// navigations loaded. Null parameter values are treated as "no filter".
/// </summary>
Task<List<PowderUsageLog>> GetForLedgerAsync(int? itemId, DateTime? from, DateTime? to);
}
@@ -0,0 +1,44 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>Lightweight projection used by the Index KPI cards — avoids loading full entities for aggregate stats.</summary>
public record PurchaseOrderStats(int TotalCount, int OpenCount, decimal CommittedValue, int OverdueCount);
/// <summary>
/// Typed repository for <see cref="PurchaseOrder"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IPurchaseOrderRepository : IRepository<PurchaseOrder>
{
/// <summary>
/// Loads a single purchase order with the full include chain required by the Details view:
/// Vendor, Bill, and Items (filtered to non-deleted) with InventoryItem navigation.
/// Returns null if not found or not owned by the current tenant.
/// </summary>
Task<PurchaseOrder?> LoadForViewAsync(int id, int companyId);
/// <summary>
/// Returns KPI aggregate stats for the Index view using a server-side projection so only three
/// columns are fetched rather than full entities.
/// </summary>
Task<PurchaseOrderStats> GetStatsAsync(int companyId);
/// <summary>
/// Returns a paged, filtered, and sorted list of purchase orders for the Index view.
/// All filter parameters are optional — passing null/empty applies no restriction for
/// that dimension.
/// </summary>
Task<(List<PurchaseOrder> Items, int TotalCount)> GetPagedAsync(
int companyId,
int pageNumber,
int pageSize,
PurchaseOrderStatus? statusFilter = null,
int? vendorId = null,
DateTime? dateFrom = null,
DateTime? dateTo = null,
string? searchTerm = null,
string? sortColumn = null,
string? sortDirection = null);
}
@@ -0,0 +1,53 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>Aggregate counts and totals for the Quotes Index stat cards.</summary>
public record QuoteIndexStats(int OpenCount, int ApprovedConvertedCount, decimal TotalValue);
/// <summary>
/// Typed repository for <see cref="Quote"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public interface IQuoteRepository : IRepository<Quote>
{
/// <summary>
/// Loads a single quote with the full include chain required by the Details view: Customer,
/// PreparedBy, QuoteStatus, OvenCost, QuoteItems with Coats (InventoryItem + Vendor),
/// CatalogItem, and PrepServices; plus QuotePrepServices (quote-level prep).
/// Returns null if not found or soft-deleted.
/// </summary>
Task<Quote?> LoadForDetailsAsync(int id);
/// <summary>
/// Loads a single quote by its customer-facing approval token. Ignores global query filters
/// so the unauthenticated approval portal can resolve any tenant's quote by token alone.
/// Includes Customer navigation. Returns null if the token does not match any live quote.
/// </summary>
Task<Quote?> GetByApprovalTokenAsync(string token);
/// <summary>
/// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
/// </summary>
Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId);
/// <summary>
/// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the
/// current company by the global query filter. Pass status ID sets (derived from QuoteStatusLookup)
/// to classify open vs. approved/converted quotes.
/// </summary>
Task<QuoteIndexStats> GetIndexStatsAsync(List<int> openStatusIds, List<int> approvedConvertedStatusIds);
/// <summary>
/// Loads quote items with their coat passes (InventoryItem + Vendor) and prep services for
/// PDF generation and quote→job conversion. Cheaper than <see cref="LoadForDetailsAsync"/>
/// because it skips the parent-quote navigations that callers already have.
/// </summary>
Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId);
/// <summary>
/// Returns the last quote number that starts with <paramref name="prefix"/> for the given
/// company (including soft-deleted quotes) for sequential number generation.
/// </summary>
Task<string?> GetLastQuoteNumberByPrefixAsync(int companyId, string prefix);
}
@@ -0,0 +1,24 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Services;
/// <summary>
/// Writes platform-wide audit trail entries. <see cref="AuditLog"/> is not a
/// <see cref="BaseEntity"/> (no soft delete, no tenant filter), so it cannot use the generic
/// <see cref="IRepository{T}"/>; this service provides the only write path for audit records
/// and keeps <c>ApplicationDbContext</c> out of controller constructors.
/// </summary>
public interface IAuditLogService
{
/// <summary>
/// Persists <paramref name="entry"/> to the audit log immediately.
/// </summary>
Task LogAsync(AuditLog entry);
/// <summary>
/// Returns the most recent <paramref name="limit"/> audit log entries for the given user
/// where <c>EntityType == "ApplicationUser"</c>, ordered newest-first. Used by the
/// SuperAdmin user login history panel.
/// </summary>
Task<List<AuditLog>> GetUserActivityAsync(string userId, int limit = 50);
}
@@ -0,0 +1,26 @@
namespace PowderCoating.Core.Interfaces.Services;
/// <summary>
/// Destructive data-purge operations for the SuperAdmin company management UI.
/// All methods use bulk <c>ExecuteDeleteAsync</c> against <c>ApplicationDbContext</c> directly;
/// they are intentional exceptions to the IUnitOfWork pattern, mirroring
/// <c>DataPurgeController</c> and <c>AccountDataExportController</c> in the documented exceptions list.
/// </summary>
public interface ICompanyDataPurgeService
{
/// <summary>
/// Deletes all business-data tables for <paramref name="companyId"/> but does NOT delete the
/// company record or Identity users. The caller is responsible for deleting users via
/// <c>UserManager</c> and the company record via <see cref="IUnitOfWork"/> after this call.
/// <paramref name="companyUserIds"/> must be loaded beforehand so announcement-dismissal
/// records that reference users (rather than the company directly) can be cleaned up.
/// </summary>
Task DeleteAllBusinessDataAsync(int companyId, IReadOnlyList<string> companyUserIds);
/// <summary>
/// Deletes all business data for <paramref name="companyId"/> while preserving the company
/// record, users, operating costs, preferences, and lookup tables. Also clears the
/// QuickBooks migration state from <c>CompanyPreferences</c>. Used by the ResetData action.
/// </summary>
Task ResetBusinessDataAsync(int companyId);
}
@@ -0,0 +1,39 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Services;
/// <summary>
/// Wizard completion metadata surfaced in the company list view.
/// </summary>
public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? CompletedByName);
/// <summary>
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
/// </summary>
public record CompanyCountSummary(
IReadOnlyDictionary<int, int> JobCounts,
IReadOnlyDictionary<int, int> QuoteCounts,
IReadOnlyDictionary<int, int> CustomerCounts,
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo
);
/// <summary>
/// Read service for the SuperAdmin company list. Wraps queries that require
/// <c>IgnoreQueryFilters()</c>, dynamic search/sort, and cross-entity GROUP BY aggregations —
/// patterns the generic <see cref="IRepository{T}"/> cannot express.
/// </summary>
public interface ICompanyListService
{
/// <summary>
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
/// total unfiltered count for pagination.
/// </summary>
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
/// <summary>
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
/// company IDs in three GROUP BY queries instead of N+1 individual lookups.
/// </summary>
Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds);
}
@@ -0,0 +1,142 @@
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Services;
/// <summary>
/// Result record carrying the pre-sliced entity lists and aggregates needed to render the
/// operator dashboard index view. The read service does the heavy SQL filtering so the
/// controller can focus on lightweight DTO projection and view assembly.
/// </summary>
public record DashboardIndexData(
int ActiveJobsCount,
int TodaysJobsCount,
List<Job> TodaysJobs,
int OverdueJobsCount,
List<Job> OverdueJobs,
List<Job> InProgressJobs,
int TodaysAppointmentsCount,
List<Appointment> TodaysAppointments,
int LowStockCount,
List<InventoryItem> LowStockItems,
int PendingMaintenanceCount,
List<MaintenanceRecord> UpcomingMaintenance,
int PendingQuotesCount,
decimal PendingQuoteValue,
List<Quote> PendingQuotes,
List<Quote> ExpiringQuotes,
int ActiveCustomersCount,
decimal MonthlyRevenue,
decimal OutstandingAr,
decimal InvoicedThisMonth,
decimal CollectedThisMonth,
int OverdueInvoicesCount,
decimal OverdueInvoicesAmount,
DashboardArAgingData ArAging,
List<Invoice> OverdueInvoices,
List<Payment> RecentPayments,
List<Quote> RecentQuotes,
List<Job> RecentJobs,
List<Equipment> EquipmentAlerts,
List<DashboardPowderOrderLineData> PowderOrdersNeeded,
List<DashboardPowderOrderLineData> PowderOrdersPlaced,
int BillsDueCount,
decimal BillsDueAmount,
List<Bill> BillsDue,
string? TipOfTheDay
);
/// <summary>
/// AR aging bucket totals used by the dashboard receivables summary.
/// </summary>
public record DashboardArAgingData(
decimal Current,
decimal Days1To30,
decimal Days31To60,
decimal Days61To90,
decimal DaysOver90
);
/// <summary>
/// Flattened powder-order line data so the controller does not need to materialize full job/item/coat graphs.
/// </summary>
public record DashboardPowderOrderLineData(
int CoatId,
int JobId,
string JobNumber,
string CustomerName,
string CoatName,
string? ColorName,
string? ColorCode,
string? Finish,
string? SKU,
decimal LbsToOrder,
decimal? CostPerLb,
DateTime? OrderedAt,
bool HasInventoryItem,
int? VendorId,
string? VendorName,
string? VendorPhone,
string? VendorEmail
);
/// <summary>
/// Aggregated data for the SuperAdmin dashboard.
/// </summary>
public record SuperAdminDashboardData(
int TotalCompanies,
int ActiveCompanies,
int InactiveCompanies,
int TotalUsers,
int ActiveSubscriptions,
int GracePeriodCount,
int ExpiredCount,
Dictionary<int, DashboardPlanDistributionData> PlanDistribution,
List<SuperAdminCompanyAlertData> CompanyAlerts,
List<SuperAdminRecentCompanyData> RecentCompanies
);
public record DashboardPlanDistributionData(
string DisplayName,
int Count
);
public record SuperAdminCompanyAlertData(
int Id,
string CompanyName,
int Plan,
string PlanDisplayName,
SubscriptionStatus Status,
DateTime? SubscriptionEndDate,
int DaysOverdue,
bool IsActive
);
public record SuperAdminRecentCompanyData(
int Id,
string CompanyName,
int Plan,
string PlanDisplayName,
SubscriptionStatus Status,
bool IsActive,
DateTime CreatedAt
);
/// <summary>
/// Read-only service for the dashboard. All methods execute complex queries that require
/// ThenInclude chains or navigation-property predicates beyond what the generic
/// <see cref="IRepository{T}"/> can express. Lives in Infrastructure so <c>ApplicationDbContext</c>
/// is available; injected into the controller via DI.
/// </summary>
public interface IDashboardReadService
{
/// <summary>Fetches all data needed to render the tenant operator dashboard.</summary>
/// <param name="today">The local date used for date-range predicates (today, start-of-month, etc.).</param>
Task<DashboardIndexData> GetIndexDataAsync(DateTime today);
/// <summary>Fetches all data needed to render the SuperAdmin dashboard.</summary>
Task<SuperAdminDashboardData> GetSuperAdminDashboardDataAsync(DateTime today);
/// <summary>Returns the total count of tenant users (CompanyId > 0) for the SuperAdmin dashboard.</summary>
Task<int> GetTotalUserCountAsync();
}
@@ -0,0 +1,2 @@
// Moved to PowderCoating.Application.Interfaces.IFinancialReportService — Application layer owns DTO-returning service interfaces.
namespace PowderCoating.Core.Interfaces.Services;
@@ -0,0 +1,2 @@
// Moved to PowderCoating.Application.Interfaces.IOperationalReportService — Application layer owns DTO-returning service interfaces.
namespace PowderCoating.Core.Interfaces.Services;
@@ -143,6 +143,9 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
/// <summary>Tenant company records. Soft-delete only filter applies (no CompanyId filter — SuperAdmin manages all companies).</summary>
public DbSet<Company> Companies { get; set; }
/// <summary>Immutable audit trail of SMS terms-of-service acceptances per company. Tenant-filtered by CompanyId; never soft-deleted.</summary>
public DbSet<CompanySmsAgreement> CompanySmsAgreements { get; set; }
/// <summary>AI quote-item predictions; tenant-filtered. Both <c>QuoteItem</c> and <c>JobItem</c> share a single prediction record via nullable FK (no duplication on quote→job conversion).</summary>
public DbSet<AiItemPrediction> AiItemPredictions { get; set; }
@@ -260,6 +263,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
public DbSet<CatalogCategory> CatalogCategories { get; set; }
/// <summary>Pre-priced service catalog items that can be added to quotes/jobs; tenant-filtered with soft delete.</summary>
public DbSet<CatalogItem> CatalogItems { get; set; }
/// <summary>Most-recent AI price-check report per company; tenant-filtered with soft delete.</summary>
public DbSet<CatalogPriceCheckReport> CatalogPriceCheckReports { get; set; }
// Notifications
/// <summary>Log of all outbound notifications (email, SMS, in-app) for audit and retry; tenant-filtered with soft delete.</summary>
@@ -385,6 +390,13 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
/// </summary>
public DbSet<PendingRegistrationSession> PendingRegistrationSessions { get; set; }
/// <summary>
/// WebAuthn passkey credentials registered by users for biometric login (Face ID, fingerprint).
/// No global query filter — the login flow queries by credentialId before authentication,
/// requiring cross-tenant lookup. Per-user isolation is enforced in the controller.
/// </summary>
public DbSet<UserPasskey> UserPasskeys { get; set; }
/// <summary>
/// Configures the EF Core model: applies entity type configurations from the assembly,
/// registers global query filters, defines relationships, adds performance indexes, and seeds
@@ -501,6 +513,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CatalogItem>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CatalogPriceCheckReport>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<Appointment>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
@@ -792,9 +806,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);
}
@@ -912,17 +912,6 @@ New accounts walk through an 18-step setup wizard to configure company informati
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{
NotificationType = NotificationType.JobReadyForPickup,
Channel = NotificationChannel.Sms,
DisplayName = "Job Ready for Pickup (SMS)",
Subject = null,
Body = "{{companyName}}: Job {{jobNumber}} is ready for pickup! Reply STOP to opt out.",
IsActive = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{
NotificationType = NotificationType.JobCompleted,
Channel = NotificationChannel.Email,
@@ -1204,6 +1193,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
await context.SaveChangesAsync();
}
// Enable AllowSms for Pro and Enterprise plans if not already set
var smsPlansToFix = await context.SubscriptionPlanConfigs.IgnoreQueryFilters()
.Where(c => (c.Plan == 1 || c.Plan == 2) && !c.AllowSms)
.ToListAsync();
if (smsPlansToFix.Count > 0)
{
foreach (var row in smsPlansToFix)
row.AllowSms = true;
await context.SaveChangesAsync();
}
// Only seed if table is empty
if (await context.SubscriptionPlanConfigs.IgnoreQueryFilters().AnyAsync())
return;
@@ -1256,6 +1256,7 @@ New accounts walk through an 18-step setup wizard to configure company informati
MaxCatalogItems = 500,
MonthlyPrice = 79m,
AnnualPrice = 790m,
AllowSms = true,
IsActive = true,
SortOrder = 3,
CompanyId = 0,
@@ -1273,6 +1274,7 @@ New accounts walk through an 18-step setup wizard to configure company informati
MaxCatalogItems = -1,
MonthlyPrice = 199m,
AnnualPrice = 1990m,
AllowSms = true,
IsActive = true,
SortOrder = 4,
CompanyId = 0,
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));
}
}
}
@@ -0,0 +1,132 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGuidedActivationFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "FirstInvoiceCreatedAt",
table: "CompanyPreferences",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "FirstJobCreatedAt",
table: "CompanyPreferences",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "FirstQuoteCreatedAt",
table: "CompanyPreferences",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "FirstWorkflowCompleted",
table: "CompanyPreferences",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "FirstWorkflowCompletedAt",
table: "CompanyPreferences",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "GuidedActivationDismissedAt",
table: "CompanyPreferences",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "OnboardingPath",
table: "CompanyPreferences",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FirstInvoiceCreatedAt",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "FirstJobCreatedAt",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "FirstQuoteCreatedAt",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "FirstWorkflowCompleted",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "FirstWorkflowCompletedAt",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "GuidedActivationDismissedAt",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "OnboardingPath",
table: "CompanyPreferences");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5921));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5931));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 26, 14, 28, 21, 454, DateTimeKind.Utc).AddTicks(5932));
}
}
}
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJobQuoteSnapshotUpdatedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "QuoteSnapshotUpdatedAt",
table: "Jobs",
type: "datetime2",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4877));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4884));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4886));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "QuoteSnapshotUpdatedAt",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5055));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5063));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 28, 16, 40, 22, 359, DateTimeKind.Utc).AddTicks(5065));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddInventoryItemImageUrl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ImageUrl",
table: "InventoryItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ImageUrl",
table: "InventoryItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4877));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4884));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 0, 14, 747, DateTimeKind.Utc).AddTicks(4886));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddSmsGating : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowSms",
table: "SubscriptionPlanConfigs",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SmsDisabledByAdmin",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SmsEnabled",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(523));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(529));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(531));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowSms",
table: "SubscriptionPlanConfigs");
migrationBuilder.DropColumn(
name: "SmsDisabledByAdmin",
table: "Companies");
migrationBuilder.DropColumn(
name: "SmsEnabled",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCompanySmsAgreement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CompanySmsAgreements",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
AgreedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
AgreedByUserName = table.Column<string>(type: "nvarchar(max)", nullable: false),
AgreedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IpAddress = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserAgent = table.Column<string>(type: "nvarchar(max)", nullable: true),
TermsVersion = table.Column<string>(type: "nvarchar(max)", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CompanySmsAgreements", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CompanySmsAgreements");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(523));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(529));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(531));
}
}
}
@@ -555,6 +555,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PasskeyPromptDismissed")
.HasColumnType("bit");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
@@ -1416,6 +1419,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<string>("ImagePath")
.HasColumnType("nvarchar(max)");
b.Property<int?>("InventoryItemId")
.HasColumnType("int");
@@ -1438,6 +1444,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("SKU")
.HasColumnType("nvarchar(max)");
b.Property<string>("ThumbnailPath")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
@@ -1459,6 +1468,57 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CatalogItems");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CatalogPriceCheckReport", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("ItemsChecked")
.HasColumnType("int");
b.Property<string>("OperatingCostsSummary")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ResultsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("RunAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("CatalogPriceCheckReports");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Company", b =>
{
b.Property<int>("Id")
@@ -1473,6 +1533,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<bool>("AiCatalogPriceCheckEnabled")
.HasColumnType("bit");
b.Property<bool>("AiInventoryAssistEnabled")
.HasColumnType("bit");
@@ -1579,6 +1642,12 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("SmsDisabledByAdmin")
.HasColumnType("bit");
b.Property<bool>("SmsEnabled")
.HasColumnType("bit");
b.Property<string>("State")
.HasColumnType("nvarchar(max)");
@@ -1906,6 +1975,24 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("EmailNotificationsEnabled")
.HasColumnType("bit");
b.Property<DateTime?>("FirstInvoiceCreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("FirstJobCreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("FirstQuoteCreatedAt")
.HasColumnType("datetime2");
b.Property<bool>("FirstWorkflowCompleted")
.HasColumnType("bit");
b.Property<DateTime?>("FirstWorkflowCompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("GuidedActivationDismissedAt")
.HasColumnType("datetime2");
b.Property<string>("InAccentColor")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -1954,6 +2041,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("NotifyOnQuoteApproval")
.HasColumnType("bit");
b.Property<string>("OnboardingPath")
.HasColumnType("nvarchar(max)");
b.Property<string>("PaymentReminderDays")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -2032,6 +2122,64 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CompanyPreferences");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CompanySmsAgreement", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AgreedAt")
.HasColumnType("datetime2");
b.Property<string>("AgreedByUserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("AgreedByUserName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("IpAddress")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("TermsVersion")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserAgent")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("CompanySmsAgreements");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ContactSubmission", b =>
{
b.Property<int>("Id")
@@ -3097,6 +3245,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("HasSamplePanel")
.HasColumnType("bit");
b.Property<string>("ImageUrl")
.HasColumnType("nvarchar(max)");
b.Property<int?>("InventoryAccountId")
.HasColumnType("int");
@@ -3648,6 +3799,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("QuoteId")
.HasColumnType("int");
b.Property<DateTime?>("QuoteSnapshotUpdatedAt")
.HasColumnType("datetime2");
b.Property<decimal>("QuotedPrice")
.HasColumnType("decimal(18,2)");
@@ -5776,7 +5930,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7155),
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5787,7 +5941,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7162),
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5798,7 +5952,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 24, 23, 28, 22, 104, DateTimeKind.Utc).AddTicks(7164),
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7126,6 +7280,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("AllowAccounting")
.HasColumnType("bit");
b.Property<bool>("AllowAiCatalogPriceCheck")
.HasColumnType("bit");
b.Property<bool>("AllowAiInventoryAssist")
.HasColumnType("bit");
@@ -7135,6 +7292,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("AllowOnlinePayments")
.HasColumnType("bit");
b.Property<bool>("AllowSms")
.HasColumnType("bit");
b.Property<decimal>("AnnualPrice")
.HasColumnType("decimal(18,2)");
@@ -7249,6 +7409,53 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("TermsAcceptances");
});
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<byte[]>("CredentialId")
.IsRequired()
.HasColumnType("varbinary(900)");
b.Property<string>("DeviceFriendlyName")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("datetime2");
b.Property<byte[]>("PublicKey")
.IsRequired()
.HasColumnType("varbinary(max)");
b.Property<long>("SignCount")
.HasColumnType("bigint");
b.Property<byte[]>("UserHandle")
.IsRequired()
.HasColumnType("varbinary(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CredentialId")
.IsUnique();
b.ToTable("UserPasskeys");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{
b.Property<int>("Id")
@@ -21,6 +21,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Stripe.net" Version="50.4.1" />
<PackageReference Include="Twilio" Version="7.14.3" />
</ItemGroup>
@@ -0,0 +1,105 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Bill"/> that provides domain-specific multi-level
/// include queries previously expressed inline in <c>BillsController</c>.
/// </summary>
public class BillRepository : Repository<Bill>, IBillRepository
{
public BillRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<Bill?> LoadForViewAsync(int id)
{
return await _context.Bills
.Where(b => b.Id == id && !b.IsDeleted)
.Include(b => b.Vendor)
.Include(b => b.APAccount)
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.ThenInclude(li => li.Account)
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.ThenInclude(li => li.Job)
.Include(b => b.Payments.Where(p => !p.IsDeleted))
.ThenInclude(p => p.BankAccount)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<Bill?> LoadForEditAsync(int id)
{
return await _context.Bills
.Where(b => b.Id == id && !b.IsDeleted)
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount)
{
var query = _context.Bills
.Include(b => b.Vendor)
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.Where(b => !b.IsDeleted);
if (statusFilter == "Unpaid")
query = query.Where(b => b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid);
else if (statusFilter == "Overdue")
query = query.Where(b => b.Status != BillStatus.Paid && b.Status != BillStatus.Voided &&
b.DueDate.HasValue && b.DueDate.Value.Date < DateTime.Today);
if (!string.IsNullOrEmpty(searchTerm))
{
var term = searchTerm;
query = query.Where(b =>
b.BillNumber.Contains(term) ||
b.Vendor.CompanyName.Contains(term) ||
(b.VendorInvoiceNumber != null && b.VendorInvoiceNumber.Contains(term)) ||
(b.Memo != null && b.Memo.Contains(term)) ||
b.LineItems.Any(li => li.Description.Contains(term)) ||
(searchAmount.HasValue && (b.Total == searchAmount.Value || b.AmountPaid == searchAmount.Value)));
}
return await query.OrderByDescending(b => b.BillDate).ToListAsync();
}
/// <inheritdoc/>
public async Task<string?> GetLastBillNumberAsync(string prefix)
{
return await _context.Bills
.IgnoreQueryFilters()
.Where(b => b.BillNumber.StartsWith(prefix))
.OrderByDescending(b => b.BillNumber)
.Select(b => b.BillNumber)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<string?> GetLastPaymentNumberAsync(string prefix)
{
return await _context.BillPayments
.IgnoreQueryFilters()
.Where(p => p.PaymentNumber.StartsWith(prefix))
.OrderByDescending(p => p.PaymentNumber)
.Select(p => p.PaymentNumber)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end)
{
return await _context.Bills
.Where(b => !b.IsDeleted && b.BillDate >= start && b.BillDate <= end)
.Include(b => b.Vendor)
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
.ThenInclude(li => li.Account)
.Include(b => b.Payments.Where(p => !p.IsDeleted))
.OrderBy(b => b.BillDate)
.ToListAsync();
}
}
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Customer"/> that adds domain-specific queries on top of
/// the generic CRUD interface.
/// </summary>
public class CustomerRepository : Repository<Customer>, ICustomerRepository
{
public CustomerRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<Customer?> LoadForDetailsAsync(int id)
{
return await _context.Customers
.Where(c => c.Id == id && !c.IsDeleted)
.Include(c => c.PricingTier)
.Include(c => c.CustomerNotes.Where(n => !n.IsDeleted))
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<Customer?> FindByEmailAsync(string email)
{
return await _context.Customers
.FirstOrDefaultAsync(c => c.Email == email && !c.IsDeleted);
}
}
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="InventoryTransaction"/> that adds a dynamic-filter
/// ledger query on top of the generic <see cref="Repository{T}"/>.
/// </summary>
public class InventoryTransactionRepository : Repository<InventoryTransaction>, IInventoryTransactionRepository
{
public InventoryTransactionRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<List<InventoryTransaction>> GetForLedgerAsync(
int? itemId,
DateTime? from,
DateTime? to,
InventoryTransactionType? type)
{
var query = _context.InventoryTransactions
.AsNoTracking()
.Include(t => t.InventoryItem)
.Include(t => t.PurchaseOrder)
.Include(t => t.Job)
.Where(t => !t.IsDeleted);
if (itemId.HasValue)
query = query.Where(t => t.InventoryItemId == itemId.Value);
if (from.HasValue)
query = query.Where(t => t.TransactionDate >= from.Value);
if (to.HasValue)
query = query.Where(t => t.TransactionDate < to.Value.AddDays(1));
if (type.HasValue)
query = query.Where(t => t.TransactionType == type.Value);
return await query
.OrderByDescending(t => t.TransactionDate)
.Take(500)
.ToListAsync();
}
}
@@ -0,0 +1,104 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Invoice"/> that provides domain-specific multi-level
/// include queries previously expressed inline in <c>InvoicesController.LoadInvoiceForViewAsync</c>.
/// </summary>
public class InvoiceRepository : Repository<Invoice>, IInvoiceRepository
{
public InvoiceRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<Invoice?> LoadForViewAsync(int id)
{
return await _context.Set<Invoice>()
.Where(i => i.Id == id && !i.IsDeleted)
.Include(i => i.Customer)
.Include(i => i.Job)
.Include(i => i.PreparedBy)
.Include(i => i.SalesTaxAccount)
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
.ThenInclude(ii => ii.RevenueAccount)
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
.ThenInclude(ii => ii.GeneratedGiftCertificate)
.Include(i => i.Payments.Where(p => !p.IsDeleted))
.ThenInclude(p => p.RecordedBy)
.Include(i => i.Payments.Where(p => !p.IsDeleted))
.ThenInclude(p => p.DepositAccount)
.Include(i => i.Refunds.Where(r => !r.IsDeleted))
.ThenInclude(r => r.IssuedBy)
.Include(i => i.CreditApplications.Where(ca => !ca.IsDeleted))
.ThenInclude(ca => ca.CreditMemo)
.Include(i => i.GiftCertificateRedemptions)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<Invoice?> GetForJobAsync(int jobId, bool includeDeleted = false)
{
var query = _context.Set<Invoice>().Where(i => i.JobId == jobId);
if (!includeDeleted)
query = query.Where(i => !i.IsDeleted);
else
query = query.IgnoreQueryFilters().Where(i => i.JobId == jobId);
return await query.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<Invoice?> GetByPaymentTokenAsync(string token)
{
return await _context.Set<Invoice>()
.IgnoreQueryFilters()
.Include(i => i.Customer)
.Include(i => i.Job)
.Include(i => i.InvoiceItems.Where(ii => !ii.IsDeleted))
.FirstOrDefaultAsync(i => i.PaymentLinkToken == token && !i.IsDeleted);
}
/// <inheritdoc/>
public async Task<string?> GetLastInvoiceNumberByPrefixAsync(int companyId, string prefix)
{
return await _context.Set<Invoice>()
.IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && i.InvoiceNumber.StartsWith(prefix))
.OrderByDescending(i => i.InvoiceNumber)
.Select(i => i.InvoiceNumber)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<List<Invoice>> GetOnlineInvoicesForPeriodAsync(int companyId, DateTime from, DateTime to)
{
return await _context.Set<Invoice>()
.AsNoTracking()
.Include(i => i.Customer)
.Where(i => !i.IsDeleted
&& i.CompanyId == companyId
&& i.OnlineAmountPaid > 0
&& i.UpdatedAt >= from
&& i.UpdatedAt < to)
.OrderByDescending(i => i.UpdatedAt)
.ToListAsync();
}
/// <inheritdoc/>
public async Task<List<Refund>> GetOnlineRefundsForPeriodAsync(int companyId, DateTime from, DateTime to)
{
return await _context.Set<Refund>()
.AsNoTracking()
.Include(r => r.Invoice).ThenInclude(inv => inv!.Customer)
.Where(r => !r.IsDeleted
&& r.CompanyId == companyId
&& r.RefundMethod == PaymentMethod.CreditDebitCard
&& r.RefundDate >= from
&& r.RefundDate < to)
.OrderByDescending(r => r.RefundDate)
.ToListAsync();
}
}
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="JobItemCoat"/> that adds ThenInclude-based load methods the
/// generic <see cref="Repository{T}"/> cannot express.
/// </summary>
public class JobItemCoatRepository : Repository<JobItemCoat>, IJobItemCoatRepository
{
public JobItemCoatRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<JobItemCoat?> LoadForOrderMarkingAsync(int id)
{
return await _context.JobItemCoats
.Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer)
.Include(c => c.Vendor)
.Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor)
.FirstOrDefaultAsync(c => c.Id == id);
}
/// <inheritdoc/>
public async Task<JobItemCoat?> LoadWithInventoryAsync(int id)
{
return await _context.JobItemCoats
.Include(c => c.InventoryItem)
.FirstOrDefaultAsync(c => c.Id == id);
}
/// <inheritdoc/>
public async Task<JobItemCoat?> LoadWithJobChainAsync(int id)
{
return await _context.JobItemCoats
.Include(c => c.JobItem).ThenInclude(i => i.Job)
.FirstOrDefaultAsync(c => c.Id == id);
}
/// <inheritdoc/>
public async Task<List<JobItemCoat>> GetCandidateCoatsForLinkingAsync(int excludeCoatId, int companyId)
{
return await _context.JobItemCoats
.Include(c => c.JobItem)
.Where(c => c.Id != excludeCoatId
&& c.InventoryItemId == null
&& c.JobItem.CompanyId == companyId)
.ToListAsync();
}
}
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="JobPhoto"/> that provides inventory-specific photo
/// lookup queries requiring multi-level ThenInclude chains that the generic
/// <see cref="Repository{T}"/> cannot express.
/// </summary>
public class JobPhotoRepository : Repository<JobPhoto>, IJobPhotoRepository
{
public JobPhotoRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<List<JobPhoto>> GetTaggedPhotosAsync(string? colorName, string? itemName)
{
var query = _context.JobPhotos
.AsNoTracking()
.Include(p => p.Job)
.ThenInclude(j => j!.Customer)
.Where(p => !p.IsDeleted && p.Tags != null && p.Tags != "");
if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(itemName) && colorName != itemName)
query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(itemName));
else if (!string.IsNullOrEmpty(colorName))
query = query.Where(p => p.Tags!.ToLower().Contains(colorName));
else if (!string.IsNullOrEmpty(itemName))
query = query.Where(p => p.Tags!.ToLower().Contains(itemName));
return await query.OrderByDescending(p => p.UploadedDate).ToListAsync();
}
/// <inheritdoc/>
public async Task<List<JobPhoto>> GetPhotosByPowderItemAsync(int inventoryItemId)
{
return await _context.JobPhotos
.AsNoTracking()
.Include(p => p.Job)
.ThenInclude(j => j!.Customer)
.Include(p => p.Job)
.ThenInclude(j => j!.JobItems)
.ThenInclude(ji => ji.Coats)
.Where(p => !p.IsDeleted &&
p.Job != null &&
p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == inventoryItemId)))
.OrderByDescending(p => p.UploadedDate)
.ToListAsync();
}
}
@@ -0,0 +1,190 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="Job"/> that provides domain-specific multi-level
/// include queries that the generic <see cref="Repository{T}"/> cannot express.
/// The base class handles all standard CRUD operations; this class adds read queries
/// that were previously scattered as inline EF expressions inside controllers.
/// </summary>
public class JobRepository : Repository<Job>, IJobRepository
{
public JobRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<List<Job>> GetBoardJobsAsync()
{
return await _context.Jobs
.AsNoTracking()
.Where(j => !j.IsDeleted)
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Include(j => j.AssignedUser)
.OrderBy(j => j.DueDate.HasValue ? 0 : 1)
.ThenBy(j => j.DueDate)
.ThenBy(j => j.JobPriority!.DisplayOrder)
.ToListAsync();
}
/// <inheritdoc/>
public async Task<Job?> LoadForDetailsAsync(int id)
{
// Single query replaces the per-item N+1 loop that was in JobsController.Details.
// EF Core splits the multi-level ThenIncludes across two SQL queries automatically
// (split query behavior), keeping result set size manageable.
return await _context.Jobs
.Where(j => j.Id == id && !j.IsDeleted)
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Include(j => j.AssignedUser)
.Include(j => j.Quote)
.Include(j => j.OvenCost)
.Include(j => j.OriginalJob)
.Include(j => j.IntakeCheckedBy)
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
.ThenInclude(ji => ji.Coats)
.ThenInclude(c => c.InventoryItem)
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
.ThenInclude(ji => ji.Coats)
.ThenInclude(c => c.Vendor)
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
.ThenInclude(ji => ji.PrepServices)
.ThenInclude(ps => ps.PrepService)
.Include(j => j.JobPrepServices.Where(jps => !jps.IsDeleted))
.ThenInclude(jps => jps.PrepService)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<Job?> LoadForEditAsync(int id)
{
return await _context.Jobs
.Where(j => j.Id == id && !j.IsDeleted)
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Include(j => j.AssignedUser)
.Include(j => j.OvenCost)
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
.ThenInclude(ji => ji.Coats)
.ThenInclude(c => c.InventoryItem)
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
.ThenInclude(ji => ji.Coats)
.ThenInclude(c => c.Vendor)
.Include(j => j.JobItems.Where(ji => !ji.IsDeleted))
.ThenInclude(ji => ji.PrepServices)
.ThenInclude(ps => ps.PrepService)
.Include(j => j.JobPrepServices.Where(jps => !jps.IsDeleted))
.ThenInclude(jps => jps.PrepService)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<Job?> LoadForStatusChangeAsync(int id)
{
return await _context.Jobs
.Include(j => j.JobStatus)
.FirstOrDefaultAsync(j => j.Id == id && !j.IsDeleted);
}
/// <inheritdoc/>
public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId)
{
return await _context.JobChangeHistories
.Where(h => h.JobId == jobId && !h.IsDeleted)
.Include(h => h.ChangedBy)
.OrderByDescending(h => h.ChangedAt)
.AsNoTracking()
.ToListAsync();
}
/// <inheritdoc/>
public async Task<string?> GetLastJobNumberByPrefixAsync(int companyId, string prefix)
{
return await _context.Jobs
.IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && j.JobNumber.StartsWith(prefix))
.OrderByDescending(j => j.JobNumber)
.Select(j => j.JobNumber)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<Job?> GetOrphanedConversionJobAsync(int quoteId, int companyId)
{
return await _context.Jobs
.IgnoreQueryFilters()
.FirstOrDefaultAsync(j => j.QuoteId == quoteId && j.CompanyId == companyId);
}
/// <inheritdoc/>
public async Task<List<Job>> GetScheduledJobsForDateAsync(DateTime date, string? userId = null)
{
var query = _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Include(j => j.AssignedUser)
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats)
.Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == date.Date
&& !j.IsDeleted && !j.JobStatus.IsTerminalStatus);
if (!string.IsNullOrEmpty(userId))
query = query.Where(j => j.AssignedUserId == userId);
return await query.ToListAsync();
}
/// <inheritdoc/>
public async Task<List<Job>> GetActiveJobsForMobileAsync(int companyId, string? workerId = null)
{
var query = _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Include(j => j.AssignedUser)
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats)
.Where(j => j.CompanyId == companyId && !j.IsDeleted && !j.JobStatus.IsTerminalStatus
&& j.JobStatus.StatusCode != "ON_HOLD" && j.JobStatus.StatusCode != "CANCELLED");
if (!string.IsNullOrEmpty(workerId))
query = query.Where(j => j.AssignedUserId == workerId);
return await query.ToListAsync();
}
/// <inheritdoc/>
public async Task<Job?> LoadForCostingAsync(int jobId, int companyId)
{
return await _context.Jobs
.Where(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.OvenCost)
.Include(j => j.Invoice)
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
.ThenInclude(t => t.Worker)
.AsNoTracking()
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<Job?> LoadForTemplateSnapshotAsync(int jobId)
{
return await _context.Jobs
.Where(j => j.Id == jobId && !j.IsDeleted)
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.FirstOrDefaultAsync();
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="JobTemplate"/> that provides domain-specific multi-level
/// include queries that the generic <see cref="Repository{T}"/> cannot express.
/// The base class handles all standard CRUD operations; this class adds the read queries
/// that require ThenInclude chains for items, coats, and prep services.
/// </summary>
public class JobTemplateRepository : Repository<JobTemplate>, IJobTemplateRepository
{
public JobTemplateRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<JobTemplate?> LoadForDetailsAsync(int id)
{
return await _context.JobTemplates
.Where(t => t.Id == id && !t.IsDeleted)
.Include(t => t.Customer)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.ThenInclude(c => c.InventoryItem)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.ThenInclude(p => p.PrepService)
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<List<JobTemplate>> GetAllActiveWithFullIncludesAsync()
{
return await _context.JobTemplates
.Where(t => !t.IsDeleted && t.IsActive)
.Include(t => t.Customer)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.ThenInclude(p => p.PrepService)
.OrderBy(t => t.Name)
.ToListAsync();
}
}

Some files were not shown because too many files have changed in this diff Show More