Add TCPA-compliant SMS consent tracking for prospect quotes
- Quote entity: ProspectSmsConsent (bool) + ProspectSmsConsentedAt (DateTime?) fields - QuoteDtos: consent fields on Create/Update/Convert DTOs with TCPA guidance text - Quote Create/Edit views: SMS consent checkbox shown when mobile number is entered - Quote ConvertToCustomer view: staff must re-confirm consent carries over to customer record - QuoteApproval: consent state exposed in ViewModel and ApprovalPage for transparency - Consent timestamp cleared when prospect quote is linked to an existing customer - Migration: AddProspectSmsConsent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,8 @@ public class QuoteDto
|
|||||||
public string? ProspectCity { get; set; }
|
public string? ProspectCity { get; set; }
|
||||||
public string? ProspectState { get; set; }
|
public string? ProspectState { get; set; }
|
||||||
public string? ProspectZipCode { get; set; }
|
public string? ProspectZipCode { get; set; }
|
||||||
|
public bool ProspectSmsConsent { get; set; }
|
||||||
|
public DateTime? ProspectSmsConsentedAt { get; set; }
|
||||||
|
|
||||||
public string? PreparedById { get; set; }
|
public string? PreparedById { get; set; }
|
||||||
public string? PreparedByName { get; set; }
|
public string? PreparedByName { get; set; }
|
||||||
@@ -186,6 +188,9 @@ public class CreateQuoteDto
|
|||||||
[StringLength(10)]
|
[StringLength(10)]
|
||||||
public string? ProspectZipCode { get; set; }
|
public string? ProspectZipCode { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "SMS Consent")]
|
||||||
|
public bool ProspectSmsConsent { get; set; } = false;
|
||||||
|
|
||||||
// Oven Selection
|
// Oven Selection
|
||||||
[Display(Name = "Oven")]
|
[Display(Name = "Oven")]
|
||||||
public int? OvenCostId { get; set; }
|
public int? OvenCostId { get; set; }
|
||||||
@@ -322,6 +327,9 @@ public class UpdateQuoteDto
|
|||||||
[StringLength(10)]
|
[StringLength(10)]
|
||||||
public string? ProspectZipCode { get; set; }
|
public string? ProspectZipCode { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "SMS Consent")]
|
||||||
|
public bool ProspectSmsConsent { get; set; } = false;
|
||||||
|
|
||||||
// Oven Selection
|
// Oven Selection
|
||||||
[Display(Name = "Oven")]
|
[Display(Name = "Oven")]
|
||||||
public int? OvenCostId { get; set; }
|
public int? OvenCostId { get; set; }
|
||||||
@@ -685,6 +693,16 @@ public class ConvertQuoteToCustomerDto
|
|||||||
[Display(Name = "Notes")]
|
[Display(Name = "Notes")]
|
||||||
[DataType(DataType.MultilineText)]
|
[DataType(DataType.MultilineText)]
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Staff must explicitly confirm verbal SMS consent before it carries over to the new customer record.
|
||||||
|
/// Pre-checked when ProspectSmsConsent was true on the source quote.
|
||||||
|
/// </summary>
|
||||||
|
[Display(Name = "Customer has given verbal consent to receive SMS notifications")]
|
||||||
|
public bool SmsConsent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Timestamp from the source quote — preserved so the consent record reflects when consent was originally given.</summary>
|
||||||
|
public DateTime? ProspectSmsConsentedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -745,6 +763,16 @@ public class CreateQuoteItemCoatDto
|
|||||||
/// When true, the additional layer labor charge is not applied even if this is not the first coat.
|
/// When true, the additional layer labor charge is not applied even if this is not the first coat.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool NoExtraLayerCharge { get; set; }
|
public bool NoExtraLayerCharge { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Platform powder catalog item ID selected via the Custom tab lookup.</summary>
|
||||||
|
public int? CatalogItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true (and CatalogItemId is set), the server creates a 0-balance IsIncoming inventory
|
||||||
|
/// item from the catalog entry so QR codes can be printed while the powder is in transit.
|
||||||
|
/// The coat is then linked to that new inventory record.
|
||||||
|
/// </summary>
|
||||||
|
public bool AddAsIncoming { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ public class Quote : BaseEntity
|
|||||||
public string? ProspectCity { get; set; }
|
public string? ProspectCity { get; set; }
|
||||||
public string? ProspectState { get; set; }
|
public string? ProspectState { get; set; }
|
||||||
public string? ProspectZipCode { get; set; }
|
public string? ProspectZipCode { get; set; }
|
||||||
|
// TCPA compliance: only true when staff explicitly records verbal SMS consent
|
||||||
|
public bool ProspectSmsConsent { get; set; } = false;
|
||||||
|
public DateTime? ProspectSmsConsentedAt { get; set; }
|
||||||
|
|
||||||
// Lookup foreign key (replacing enum)
|
// Lookup foreign key (replacing enum)
|
||||||
public int QuoteStatusId { get; set; }
|
public int QuoteStatusId { get; set; }
|
||||||
|
|||||||
Generated
+9537
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddProspectSmsConsent : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "ProspectSmsConsent",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "ProspectSmsConsentedAt",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5347));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5357));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5358));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProspectSmsConsent",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProspectSmsConsentedAt",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(648));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(653));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(655));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -454,6 +454,10 @@ public class QuoteApprovalController : Controller
|
|||||||
CustomerEmail = quote.Customer?.Email ?? quote.ProspectEmail,
|
CustomerEmail = quote.Customer?.Email ?? quote.ProspectEmail,
|
||||||
ExpirationDate = quote.ExpirationDate,
|
ExpirationDate = quote.ExpirationDate,
|
||||||
ApprovalTokenExpiresAt = quote.ApprovalTokenExpiresAt,
|
ApprovalTokenExpiresAt = quote.ApprovalTokenExpiresAt,
|
||||||
|
ItemsSubtotal = quote.ItemsSubtotal,
|
||||||
|
OvenBatchCost = quote.OvenBatchCost,
|
||||||
|
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||||
|
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||||
SubTotal = quote.SubTotal,
|
SubTotal = quote.SubTotal,
|
||||||
DiscountAmount = quote.DiscountAmount,
|
DiscountAmount = quote.DiscountAmount,
|
||||||
HideDiscountFromCustomer = quote.HideDiscountFromCustomer,
|
HideDiscountFromCustomer = quote.HideDiscountFromCustomer,
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ public class QuoteApprovalViewModel
|
|||||||
public string? CustomerEmail { get; set; }
|
public string? CustomerEmail { get; set; }
|
||||||
public DateTime? ExpirationDate { get; set; }
|
public DateTime? ExpirationDate { get; set; }
|
||||||
public DateTime? ApprovalTokenExpiresAt { get; set; }
|
public DateTime? ApprovalTokenExpiresAt { get; set; }
|
||||||
|
public decimal ItemsSubtotal { get; set; }
|
||||||
|
public decimal OvenBatchCost { get; set; }
|
||||||
|
public decimal ShopSuppliesAmount { get; set; }
|
||||||
|
public decimal ShopSuppliesPercent { get; set; }
|
||||||
public decimal SubTotal { get; set; }
|
public decimal SubTotal { get; set; }
|
||||||
public decimal DiscountAmount { get; set; }
|
public decimal DiscountAmount { get; set; }
|
||||||
public bool HideDiscountFromCustomer { get; set; }
|
public bool HideDiscountFromCustomer { get; set; }
|
||||||
|
|||||||
@@ -90,9 +90,30 @@
|
|||||||
<div class="row justify-content-end">
|
<div class="row justify-content-end">
|
||||||
<div class="col-sm-6 col-md-5">
|
<div class="col-sm-6 col-md-5">
|
||||||
<div class="d-flex justify-content-between mb-1">
|
<div class="d-flex justify-content-between mb-1">
|
||||||
<span class="text-muted">Subtotal</span>
|
<span class="text-muted">Items Subtotal</span>
|
||||||
|
<span>@Model.ItemsSubtotal.ToString("C")</span>
|
||||||
|
</div>
|
||||||
|
@if (Model.OvenBatchCost > 0)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span class="text-muted">Oven Processing</span>
|
||||||
|
<span>@Model.OvenBatchCost.ToString("C")</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (Model.ShopSuppliesAmount > 0)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span class="text-muted">Shop Supplies (@(Model.ShopSuppliesPercent.ToString("0.##"))%)</span>
|
||||||
|
<span>@Model.ShopSuppliesAmount.ToString("C")</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (Model.OvenBatchCost > 0 || Model.ShopSuppliesAmount > 0)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between mb-1 fw-semibold">
|
||||||
|
<span>Subtotal</span>
|
||||||
<span>@Model.SubTotal.ToString("C")</span>
|
<span>@Model.SubTotal.ToString("C")</span>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
@if (Model.DiscountAmount > 0 && !Model.HideDiscountFromCustomer)
|
@if (Model.DiscountAmount > 0 && !Model.HideDiscountFromCustomer)
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between mb-1 text-success">
|
<div class="d-flex justify-content-between mb-1 text-success">
|
||||||
|
|||||||
@@ -207,6 +207,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- SMS Notifications -->
|
||||||
|
<div class="card mb-4 border-info">
|
||||||
|
<div class="card-header bg-info bg-opacity-10">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-phone me-2"></i>SMS Notifications
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<input type="hidden" asp-for="ProspectSmsConsentedAt" />
|
||||||
|
@if (Model.SmsConsent)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success alert-permanent mb-3 py-2">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
|
<strong>SMS consent was recorded on the quote</strong>
|
||||||
|
@if (Model.ProspectSmsConsentedAt.HasValue)
|
||||||
|
{
|
||||||
|
<span>on @Model.ProspectSmsConsentedAt.Value.ToLocalTime().ToString("MM/dd/yyyy")</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning alert-permanent mb-3 py-2">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
<strong>No SMS consent was recorded on the quote.</strong>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="form-check">
|
||||||
|
<input asp-for="SmsConsent" class="form-check-input" id="SmsConsent" />
|
||||||
|
<label class="form-check-label fw-semibold" for="SmsConsent">
|
||||||
|
Customer has given verbal consent to receive SMS notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block mt-1">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>
|
||||||
|
Only check if the customer has explicitly agreed to receive text messages (TCPA compliance).
|
||||||
|
If checked, the new customer record will have SMS enabled and a confirmation text will be sent.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<button type="submit" class="btn btn-success btn-lg">
|
<button type="submit" class="btn btn-success btn-lg">
|
||||||
|
|||||||
@@ -104,6 +104,19 @@
|
|||||||
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
|
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mt-2" id="prospectSmsConsentRow" style="display:none;">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input asp-for="ProspectSmsConsent" class="form-check-input" id="ProspectSmsConsent" />
|
||||||
|
<label class="form-check-label fw-semibold" for="ProspectSmsConsent">
|
||||||
|
Customer verbally agreed to receive SMS notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>Only check if the customer explicitly consented to receive text messages (TCPA compliance).
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mt-2 quote-advanced-only">
|
<div class="row mt-2 quote-advanced-only">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="ProspectAddress" class="form-label"></label>
|
<label asp-for="ProspectAddress" class="form-label"></label>
|
||||||
@@ -764,8 +777,23 @@
|
|||||||
if (el) el.removeAttribute('required');
|
if (el) el.removeAttribute('required');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
updateProspectSmsConsentVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateProspectSmsConsentVisibility() {
|
||||||
|
const phoneEl = document.getElementById('prospectPhone');
|
||||||
|
const row = document.getElementById('prospectSmsConsentRow');
|
||||||
|
if (!phoneEl || !row) return;
|
||||||
|
const isProspect = document.getElementById('forProspect')?.checked;
|
||||||
|
row.style.display = (isProspect && phoneEl.value.trim()) ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const phoneEl = document.getElementById('prospectPhone');
|
||||||
|
if (phoneEl) phoneEl.addEventListener('input', updateProspectSmsConsentVisibility);
|
||||||
|
updateProspectSmsConsentVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
// Discount type toggle
|
// Discount type toggle
|
||||||
function onDiscountTypeChange() {
|
function onDiscountTypeChange() {
|
||||||
const type = document.getElementById('discountTypeSelect').value;
|
const type = document.getElementById('discountTypeSelect').value;
|
||||||
|
|||||||
@@ -52,10 +52,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="ProspectPhone" class="form-label">Phone <span class="text-danger">*</span></label>
|
<label asp-for="ProspectPhone" class="form-label">Phone <span class="text-danger">*</span></label>
|
||||||
<input asp-for="ProspectPhone" class="form-control" type="tel" />
|
<input asp-for="ProspectPhone" class="form-control" type="tel" id="editProspectPhone" />
|
||||||
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
|
<span asp-validation-for="ProspectPhone" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mt-2" id="editProspectSmsConsentRow">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input asp-for="ProspectSmsConsent" class="form-check-input" id="ProspectSmsConsent" />
|
||||||
|
<label class="form-check-label fw-semibold" for="ProspectSmsConsent">
|
||||||
|
Customer verbally agreed to receive SMS notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>Only check if the customer explicitly consented to receive text messages (TCPA compliance).
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mt-2">
|
<div class="row mt-2">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="ProspectAddress" class="form-label"></label>
|
<label asp-for="ProspectAddress" class="form-label"></label>
|
||||||
@@ -683,8 +696,20 @@
|
|||||||
if (custEl) new TomSelect(custEl, { placeholder: '-- Select Customer --', openOnFocus: true, maxOptions: false,
|
if (custEl) new TomSelect(custEl, { placeholder: '-- Select Customer --', openOnFocus: true, maxOptions: false,
|
||||||
onChange: function(value) { onQuoteCustomerChanged({ value: value }); }
|
onChange: function(value) { onQuoteCustomerChanged({ value: value }); }
|
||||||
});
|
});
|
||||||
|
// Show/hide SMS consent row based on phone field value
|
||||||
|
const phoneEl = document.getElementById('editProspectPhone');
|
||||||
|
if (phoneEl) {
|
||||||
|
phoneEl.addEventListener('input', updateEditProspectSmsConsent);
|
||||||
|
updateEditProspectSmsConsent();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function updateEditProspectSmsConsent() {
|
||||||
|
const phoneEl = document.getElementById('editProspectPhone');
|
||||||
|
const row = document.getElementById('editProspectSmsConsentRow');
|
||||||
|
if (phoneEl && row) row.style.display = phoneEl.value.trim() ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
function onQuoteCustomerChanged(select) {
|
function onQuoteCustomerChanged(select) {
|
||||||
const meta = JSON.parse(document.getElementById('quoteMetaData').textContent);
|
const meta = JSON.parse(document.getElementById('quoteMetaData').textContent);
|
||||||
const optOutIds = new Set(meta.emailOptOutCustomerIds || []);
|
const optOutIds = new Set(meta.emailOptOutCustomerIds || []);
|
||||||
|
|||||||
Reference in New Issue
Block a user