Commit Graph

128 Commits

Author SHA1 Message Date
spouliot 713efbc2b6 Store Data Protection keys in SQL Server (non-production)
Replaces the local filesystem path (which required IIS app pool write
access to inetpub\wwwroot\DataProtection-Keys) with SQL Server storage
via IDataProtectionKeyContext. Keys now survive deploys and IIS recycles
without any server-side folder permission setup.

Production continues to use Azure Blob Storage unchanged.

- Add Microsoft.AspNetCore.DataProtection.EntityFrameworkCore 8.0.11 to
  Web and Infrastructure projects
- ApplicationDbContext implements IDataProtectionKeyContext
- Migration AddDataProtectionKeys creates DataProtectionKeys table
- Program.cs: non-production path uses PersistKeysToDbContext

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:08:31 -04:00
spouliot c7a60a1fad Use Azure Blob Storage for Data Protection keys on non-local deployments
When Storage:ConnectionString is configured (dev/staging servers), store
Data Protection keys in Azure Blob Storage (dataprotection-dev/keys.xml)
instead of the local filesystem. Local developer workstations without a
storage connection string continue to use the filesystem fallback.

Fixes UnauthorizedAccessException on the dev IIS server caused by the app
pool identity not having permission to create the DataProtection-Keys
directory after it was wiped during a deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:54:37 -04:00
spouliot c45a6826bd Fix time entry 500 and inventory edit pencil visibility
- Remove parseInt() from time entry worker select — GUIDs were destroyed
  to NaN → sent as null → FindByIdAsync(null) threw 500
- Ledger pencil: also show for Adjustment rows (no PO) so scan-without-job
  entries get an edit button, not just JobUsage rows
- InventoryController: always write JobUsage type for scan-based logs;
  accept Adjustment in edit endpoints; promote Adjustment→JobUsage when
  a job is assigned via edit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:46:05 -04:00
spouliot 010d0437c2 Fix grace period tests: set StripeSubscriptionId on test companies
Trial companies (no StripeSubscriptionId) get 0 grace days by design.
The GracePeriod and Expired status tests need a paid subscription to
exercise the 14-day grace window correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:27:38 -04:00
spouliot 20ae11be03 Commit remaining unstaged changes from this session
- Platform settings service: IPlatformSettingsService, PlatformSettingKeys,
  PlatformSettingsService, SubscriptionService, AppConstants,
  SubscriptionExpiryBackgroundService, SubscriptionMiddleware
- JobTimeEntry entity, DTOs, AutoMapper profile (ShopWorker → UserId migration)
- InventoryDtos: SourceTransactionId on PowderUsageLogDto
- InventoryTransactionRepository: include Job.Customer in ledger query
- InventoryAiLookupService: @graph unwrap + HTML price fallback
- ApplicationDbContextModelSnapshot: reflect migration changes
- launchSettings.json, publish profile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:20:30 -04:00
spouliot 4c070e7487 Fix SubscriptionServiceTests: add IPlatformSettingsService stub
Tests broke when SubscriptionService gained the platformSettings
constructor parameter in the previous session. Add NullPlatformSettingsService
stub and pass it to all 13 test instantiations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:15:03 -04:00
spouliot 03d3f57f7b Fix time entry workers, powder usage logging, inventory edit, and mojibake
- JobTimeEntry: migrate to UserId/UserDisplayName; make ShopWorkerId nullable
  (migration MigrateTimeEntriesToUserId)
- Log Time modal: populate worker dropdown from Identity users instead of
  ShopWorkers; fix ShopMobile view same issue
- Inventory Ledger: scan-based JobUsage transactions now appear in
  Powder Usage By Job tab (synthesized from InventoryTransaction)
- Inventory Ledger: add Edit button for JobUsage transactions; new
  GetUsageForEdit + EditUsageTransaction endpoints; inventory-ledger.js
- InventoryTransactionRepository: include Job.Customer for ledger queries
- InventoryAiLookupService: handle JSON-LD @graph wrapper (Columbia
  Coatings / WooCommerce+Yoast); add HTML price snippet fallback
- Fix mojibake in 9 views: â†' → →, âœ" → ✓, âš  → ⚠

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:05:37 -04:00
spouliot 7fe8bc81c6 Exclude trial companies from MRR/ARR and revenue trend
Companies with null StripeSubscriptionId are on trial and have no real
subscription income. They were being counted as paying Active/GracePeriod
customers, inflating MRR, ARR, plan distribution, and 12-month trend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 09:10:59 -04:00
spouliot a689b1752d Fix service worker intercepting cross-origin image fetches, breaking CSP connect-src 2026-05-04 23:31:06 -04:00
spouliot 4b845d7a1a Restore linux-x64 RID for SqlClient; xcopy handles wwwroot separately 2026-05-04 23:07:47 -04:00
spouliot 56c8b71706 Use az webapp deploy --type zip --async for WEBSITE_RUN_FROM_PACKAGE=1 mode 2026-05-04 23:01:09 -04:00
spouliot 1e22acf1dc Fix missing wwwroot: remove linux-x64 RID, explicitly xcopy wwwroot after publish 2026-05-04 22:48:39 -04:00
spouliot 676f63e7dc Add wwwroot verification step after publish to diagnose missing static files 2026-05-04 22:43:12 -04:00
spouliot d75e5a02ed Switch to Kudu zip deploy (config-zip) to match manual deploy behavior 2026-05-04 22:34:07 -04:00
spouliot b92a9f78df Restore PATH, linux-x64 publish, and ZipFile deploy lost in merge conflict 2026-05-04 22:19:57 -04:00
spouliot 444748adb7 Merge branch 'dev' 2026-05-04 22:14:55 -04:00
spouliot 4c58d57928 Commit misc scripts, feature specs, SQL deploy scripts, and settings updates 2026-05-04 22:14:25 -04:00
spouliot ee3158b7d5 Hide Scan Label button on page load until coating category is selected 2026-05-04 22:13:19 -04:00
spouliot 87a235ed1d Use ZipFile.CreateFromDirectory for reliable subdirectory inclusion in zip 2026-05-04 22:08:32 -04:00
spouliot be5717ebe2 Remove linux-x64 from solution build; let publish handle RID build 2026-05-04 21:56:41 -04:00
spouliot 1392ddccda Target linux-x64 on build and publish for Azure Linux App Service 2026-05-04 21:54:25 -04:00
spouliot 06c914e65c Add Azure CLI wbin to PATH for Jenkins agent 2026-05-04 21:40:23 -04:00
spouliot 92570bb1f6 Rewrite Jenkinsfile for Windows appdev agent (bat, az CLI, EF direct update) 2026-05-04 21:33:52 -04:00
spouliot e447cdc803 Rewrite Jenkinsfile for Windows appdev agent (bat, az CLI, EF direct update) 2026-05-04 21:29:18 -04:00
spouliot 2885bc1228 Update help docs and AI knowledge base for new features
GettingStarted: add 'Using on Mobile — Add to Home Screen' section covering
iOS Safari install flow, Android Chrome install, and PWA benefits (full-screen,
persistent camera permission). Includes Safari-required warning for iOS.

Inventory: add 'Catalog Lookup & Label Scanner' section covering smart catalog
search (filters out existing inventory, vendor-scoped with fallback), AI Lookup
fallback, camera label scanner (catalog-first then AI), and the add-stock prompt
when a scanned product is already in inventory.

HelpKnowledgeBase: sync both of the above for the AI Help Assistant, plus add
catalog lookup / label scanner detail to the INVENTORY section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:35:31 -04:00
spouliot aa0efe7c6f Add PWA install banner to dashboard
Shows a dismissible banner on mobile only, tailored to three cases:
- iOS + Safari: instructions to tap Share → Add to Home Screen
- iOS + other browser: tells user to open in Safari first (required for standalone)
- Android: instructions to tap menu → Install App / Add to Home Screen

Hidden when already running as standalone PWA, or after user dismisses it
(stored in localStorage so it stays gone). Explains camera permission benefit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:29:21 -04:00
spouliot 7a5fef4741 Fix pwa-icon-192 to use new orange cloud logo
Was still pointing to the old white cloud version; regenerated from pwa-icon-512.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:11:04 -04:00
spouliot 1088af7697 Fix iOS PWA icon: add apple-touch-icon with white background
iOS ignores manifest icons for home screen; requires apple-touch-icon link tag.
Generated 180x180 PNG with white background + padding so the transparent logo
renders correctly instead of filling black.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:06:52 -04:00
spouliot 50b1794799 Add PWA manifest, fix AI multi-coat pricing, and improve catalog lookup
- PWA: manifest.json + minimal service worker so iOS/Android persist camera
  permission after "Add to Home Screen"; theme-color and apple meta tags in layout
- PWA icons: 192x192 and 512x512 from transparent PCL logo; updated pcl-logo.png
- AI pricing: apply AdditionalCoatLaborPercent per extra coat on AI items,
  matching the calculated-item path (was ignoring extra coats entirely)
- AI wizard: live price recalc when coats are added/removed; session-expiry
  errors now show a clear "refresh and sign in" message instead of raw HTTP status;
  smooth-scroll to follow-up/results sections on AI response
- Catalog lookup: exclude SKUs already in company inventory from results;
  pass currentId on edit so own entry still appears; vendor-scoped search
  with cross-vendor fallback; result count shown in multi-match modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 08:58:10 -04:00
spouliot e9e37b0bf7 Merge dev: inventory label scanner improvements and AI lookup parity 2026-05-03 20:30:44 -04:00
spouliot 7de65910e3 Extract shared catalog enrichment into EnrichFromCatalogAsync helper
AiLookup and ScanLabel were running separate catalog lookup + auto-contribute
code paths. Both now go through EnrichFromCatalogAsync so any future change
to catalog logic only needs to be made once.

- EnrichFromCatalogAsync: private helper that finds a matching PowderCatalogItem
  by SKU + manufacturer, overwrites AI-inferred spec fields with catalog values
  (catalog is authoritative), fills gaps for URL/price fields with ??=, and
  optionally auto-contributes new entries to the platform catalog. Returns
  (wasInCatalog, addedToCatalog) for callers that show UI badges.
- AiLookup: now calls EnrichFromCatalogAsync then ApplyTdsCureFallbackAsync
  before returning — same enrichment pipeline as ScanLabel.
- ScanLabel: replaced ~50-line inline catalog block with two helper calls.
  Return statement simplified from catalogMatch?.X ?? aiResult.X to just
  aiResult.X since EnrichFromCatalogAsync already merged catalog values in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:27:28 -04:00
spouliot 145da7b5c4 Apply TDS cure fallback and SDS/TDS URL filling to AI Lookup button
Previously these enrichments only ran in the label scanner path (ScanLabel).
The AI Lookup button and AiAugmentFromUrl went through separate code that
returned raw LookupAsync / LookupByUrlAsync results with no TDS fallback
and no SDS/TDS URL propagation to the form.

- InventoryController.ApplyTdsCureFallbackAsync: new private helper that
  checks whether cure temp or cure time is still null after the primary
  lookup, and if a TDS URL was returned calls FetchTdsCureSpecsAsync to
  fill the gap. Mutates the result in place so callers just return it.
- AiLookup: calls ApplyTdsCureFallbackAsync after LookupAsync succeeds.
- AiAugmentFromUrl: calls ApplyTdsCureFallbackAsync after LookupByUrlAsync.
- ScanLabel: replaced the inline TDS fallback block with a call to the
  same helper (merges catalog TDS URL into aiResult first so the helper
  sees the best available URL).
- _InventoryColorFamilyScripts.cshtml: added fillDocUrl() helper that fills
  field-sdsurl / field-tdsurl inputs and shows their open-link buttons when
  the AI lookup returns sdsUrl / tdsUrl. These fields existed in the form
  but were never populated by the AI Lookup button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:19:43 -04:00
spouliot 4182286a31 Fall back to TDS sheet for cure specs when main lookup returns none
After the main AI lookup and catalog search, if CureTemperatureF or
CureTimeMinutes is still null but a TDS URL was found, fetch that page
and ask Claude to extract just the cure schedule.

- IInventoryAiLookupService.FetchTdsCureSpecsAsync: new interface method
- InventoryAiLookupService.FetchTdsCureSpecsAsync: fetches the TDS URL via
  the existing FetchPageAsync pipeline (JSON-LD + doc-link extraction, HTML
  stripping). If the page is a PDF or unreachable, returns Success=false
  silently so no error surfaces in the UI. Otherwise sends a small targeted
  prompt that asks only for cureTemperatureF and cureTimeMinutes and uses
  MaxTokens=256 so the call is fast and cheap.
- InventoryController.ScanLabel: after catalog lookup, computes the resolved
  cure values (catalog preferred over AI result). If either is null and a
  TDS URL exists, calls FetchTdsCureSpecsAsync and merges any newly found
  values back into aiResult before building the JSON response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:14:12 -04:00
spouliot 5e3b0b9ddf Inline add-stock prompt when label scan finds existing inventory item
When a scanned label matches an item already in the tenant's inventory,
the scanner now opens an inline modal asking the user to add stock to the
existing item rather than navigating away or creating a duplicate.

- InventoryController.AddStock: new POST endpoint that creates a Purchase
  transaction, updates QuantityOnHand, and optionally updates UnitCost /
  LastPurchasePrice when a new cost is provided. Returns new balance as JSON.
- InventoryController.ScanLabel: extends the duplicate-detection response
  to include existingQuantityOnHand and existingUnitOfMeasure so the modal
  can display current stock level.
- _LabelScanModal.cshtml: adds #addStockModal with quantity (+ UOM label),
  optional unit cost (pre-filled from scan), optional notes, Add Stock CTA,
  and an escape hatch to create a new entry instead.
- inventory-label-scan.js: when scan returns existingInventoryId the JS
  opens addStockModal instead of a warning banner. Submitting POSTs to
  /Inventory/AddStock and shows the updated balance in a success bar with
  a link to the item. The 'new entry instead' path hides the modal and
  pre-fills the create form with a softer duplicate warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:59:43 -04:00
spouliot 3aeec4ffb2 Warn on label scan when product already exists in tenant inventory
After resolving manufacturer + SKU from the scan, ScanLabel now queries the
tenant's InventoryItems: first by ManufacturerPartNumber exact match (most
precise), then by ColorName + Manufacturer fuzzy match as fallback.

If a match is found, the response includes existingInventoryId and
existingInventoryName. The JS fillFromScan() shows a warning banner with a
direct link to the existing item instead of the normal success message. Form
fields are still pre-filled so the user can proceed to add a new entry (e.g.
a different lot or bag size) if that was the intent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:49:13 -04:00
spouliot 28b7b9f86b Fix QR detection (parallel loops), price extraction, and camera pre-warm
QR scanning:
- Run BarcodeDetector and jsQR in parallel — jsQR starts after JSQR_DELAY_MS
  (1.5 s) so both decode simultaneously. BarcodeDetector silently returns empty
  arrays for some QR variants; running jsQR in parallel via a separate rAF loop
  (rafId2) and its own off-screen canvas catches those cases. First decoder to
  find anything calls handleQrResult and sets qrFound = true; the other stops.

Price extraction (two bugs):
- ScanLabel: unitPrice was catalogMatch?.UnitPrice ?? 0m, ignoring aiResult
  .UnitCostPerLb entirely when no catalog match — changed to fall through to AI result
- AppendOffer: only read JSON-LD "price" field; Shopify AggregateOffer uses
  "lowPrice" instead — now checked as fallback so Prismatic Powders prices are found

Camera pre-warm:
- Reverted localStorage approach (caused getUserMedia to fire on every page load,
  showing Chrome's "Ask" prompt immediately before user clicked anything)
- Restored Permissions API gate: preWarmCamera only calls getUserMedia when
  navigator.permissions.query returns 'granted', never risks a page-load prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:45:22 -04:00
spouliot cf36e41139 Label scanner: fix QR detection, blank camera on processing, improve permission flow
QR scanning:
- BarcodeDetector now snapshots to canvas before detect() instead of passing
  live video element — more reliable across Chrome versions
- Uses BarcodeDetector.getSupportedFormats() to detect all formats the browser
  supports rather than hardcoding ['qr_code'], catching data_matrix etc.
- jsQR fallback unchanged (attemptBoth inversion)

Processing overlay:
- Added #scan-processing overlay div to _LabelScanModal with spinner + message
- Camera/scanning UI blanks immediately when QR is found or Scan Text tapped;
  overlay message differs per path ("QR code found..." vs "Reading label with AI...")
- Overlay hides on error (modal stays open); modal close triggers hideProcessing()

Camera permission:
- localStorage flag (scannerCameraGranted) set on every successful getUserMedia
- preWarmCamera() checks flag first, bypassing navigator.permissions.query which
  can return 'prompt' for localhost even when Chrome has 'Allow' internally;
  proactive getUserMedia on page load succeeds silently when permission is granted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 19:05:41 -04:00
spouliot 97cf6dcbf0 Pre-warm camera stream on page load if permission already granted
Uses Permissions API (non-prompting) to check camera state on load.
If state === 'granted', silently starts the stream so Scan Label opens
instantly with no browser prompt on subsequent page visits. Falls back
gracefully when Permissions API is unavailable or permission is 'prompt'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:48:37 -04:00
spouliot 4b65572f6f Label scanner: native BarcodeDetector + keep stream alive between opens
- Use BarcodeDetector API (Chrome/Edge/Android) as primary QR scanner; it uses
  native OS-level decoding which is far more reliable than jsQR for Prismatic's
  QR codes. Falls back to jsQR (attemptBoth) on Safari/Firefox.
- Keep MediaStream alive between modal opens so the browser does not re-prompt
  for camera permission on each scan within the same page session. Stream is
  released after 2 min of idle (IDLE_RELEASE_MS) or on page unload.
- stopCamera() split into stopQrLoop() (cancel rAF only) and releaseCamera()
  (stop tracks + null srcObject); modal hide now calls stopQrLoop, not releaseCamera.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:45:25 -04:00
spouliot f881b7dd53 Fix label scanner: full field mapping, vision follow-up lookup, SDS/TDS extraction
- LookupByUrlAsync now maps all identity + spec fields from Claude response
  (manufacturer, SKU, colorName, description, sdsUrl, tdsUrl, unitCostPerLb, etc.)
  Previously only augmenting fields were mapped; Columbia QR path left 80% blank
- Vision scan follow-up: after ScanLabelAsync reads label text, automatically run
  LookupAsync using the extracted manufacturer + color/SKU to fill SDS/TDS URLs,
  product page, image, description, and any specs not printed on the bag;
  label values (cure schedule, SKU) remain authoritative and are never overwritten
- SDS/TDS URL extraction: added ExtractDocumentLinks() that scans anchor tags in
  raw HTML before tag-stripping, injects found URLs as [Structured Data] lines so
  Claude can read and echo them back in the JSON response; previously all hrefs
  were lost with the HTML stripping
- Added SdsUrl/TdsUrl to InventoryAiLookupResult, Claude system prompt JSON schema,
  LookupAsync mapping, and ScanLabel response (catalog match ?? aiResult fallback)
- SDS/TDS now also stored on auto-contributed catalog entries
- jsQR inversionAttempts: 'dontInvert' → 'attemptBoth' for better QR detection
  under varying label contrast and lighting conditions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:22:53 -04:00
spouliot 1fc79b77fe Add platform powder catalog, catalog-first lookup, and label scanner
- Platform PowderCatalogItem table (IPlainRepository, no tenant filter) with
  full spec fields: cure temp/time, finish, color families, clear coat flag,
  coverage sq ft/lb, transfer efficiency, IsUserContributed
- Two EF migrations: AddPowderCatalogItem + AddPowderCatalogSpecFields
- PowderCatalogController (SuperAdminOnly): import from Prismatic JSON scrape,
  Lookup AJAX endpoint (catalog-first, ranked by SKU exact match), stats view
  with Tenant Contributed card
- Unified smart Lookup button on inventory Create/Edit: catalog hit fills all
  fields via catalogSnapshot pattern; AI augments cure/finish data from product
  URL if subscription enabled; catalog miss falls through to AI lookup
- In-browser label scanner (_LabelScanModal): getUserMedia live camera feed,
  jsQR auto-detects QR codes in rAF loop; "Scan Label Text" fallback sends
  captured frame to Claude vision via /Inventory/ScanLabel
- ScanLabel endpoint handles both QR URL path (LookupByUrlAsync) and vision
  path (ScanLabelAsync); auto-inserts unrecognized products as
  IsUserContributed=true; returns wasInCatalog/addedToCatalog flags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 16:36:25 -04:00
spouliot 3ee08b5e43 Widen SMS Agreements history modal to modal-xl
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 12:03:50 -04:00
spouliot 924748c631 Fix SMS Agreements history modal date column wrapping
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 11:50:40 -04:00
spouliot 3ae636d771 Fix SMS Agreements history modal showing undefined values
System.Text.Json serializes PascalCase by default but the modal JS
expected camelCase — added CamelCase naming policy to the serializer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 11:43:47 -04:00
spouliot 90f333c8f3 Fix SMS Agreements version display and auto-remove stale templates
Fix Razor rendering of TermsVersion — property chains after a literal
character need @() parentheses or Razor misparses the expression.

Also adds cleanup to EnsureNotificationTemplatesSeededAsync to remove
stale template rows (no longer canonical, never customised) on next
settings visit, so retired types like JobReadyForPickup SMS disappear
automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 11:02:25 -04:00
spouliot 0b6a7a14c4 Add Quote Sent SMS template and fix consent confirmation wording
Adds a customizable QuoteSent SMS template to seed data and
DefaultTemplates so companies can edit the quote approval message
from Notification Templates. Wires NotifyQuoteSentSmsAsync to use
the template system instead of a hardcoded string. Updates
SmsConsentConfirmation wording to mention quote approvals alongside
job updates. Help docs and AI knowledge base updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 21:19:43 -04:00
spouliot a9048dea2e Show email and SMS notification status on customer list and details
Added notification preference indicators to both views so staff can
see at a glance whether a customer has email/SMS enabled without
opening edit mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:27:28 -04:00
spouliot 3ff6a96bc8 Add SMS START/re-subscribe handling to Twilio webhook
Customers who replied STOP by mistake can now reply START, YES, or
UNSTOP to automatically re-enable their SMS opt-in — no staff action
needed. Adds SmsInboundStart notification type, HandleStartAsync in
WebhooksController, and updates AI knowledge base and help docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 20:09:49 -04:00
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