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>
This commit is contained in:
@@ -165,6 +165,9 @@ public class UpdateCompanyDto
|
||||
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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -380,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
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ public class SubscriptionPlanConfigDto
|
||||
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; }
|
||||
}
|
||||
@@ -72,6 +73,7 @@ public class UpdateSubscriptionPlanConfigDto
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -23,8 +23,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.
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -88,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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -49,6 +49,9 @@ public class SubscriptionPlanConfig : BaseEntity
|
||||
/// <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; }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+9340
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSmsGating : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowSms",
|
||||
table: "SubscriptionPlanConfigs",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SmsDisabledByAdmin",
|
||||
table: "Companies",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SmsEnabled",
|
||||
table: "Companies",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(523));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(529));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(531));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowSms",
|
||||
table: "SubscriptionPlanConfigs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SmsDisabledByAdmin",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SmsEnabled",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9398
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCompanySmsAgreement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CompanySmsAgreements",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
AgreedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
AgreedByUserName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
AgreedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
IpAddress = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UserAgent = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
TermsVersion = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CompanySmsAgreements", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CompanySmsAgreements");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(523));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(529));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 2, 0, 8, 58, 880, DateTimeKind.Utc).AddTicks(531));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1642,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)");
|
||||
|
||||
@@ -2116,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")
|
||||
@@ -5866,7 +5930,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9171),
|
||||
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4933),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -5877,7 +5941,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9177),
|
||||
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4939),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -5888,7 +5952,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 4, 29, 22, 12, 13, 993, DateTimeKind.Utc).AddTicks(9179),
|
||||
CreatedAt = new DateTime(2026, 5, 2, 0, 26, 49, 381, DateTimeKind.Utc).AddTicks(4941),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7228,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)");
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<Company>? _companies;
|
||||
private IRepository<CompanyOperatingCosts>? _companyOperatingCosts;
|
||||
private IRepository<CompanyPreferences>? _companyPreferences;
|
||||
private IRepository<CompanySmsAgreement>? _companySmsAgreements;
|
||||
|
||||
// AI Predictions
|
||||
private IRepository<AiItemPrediction>? _aiItemPredictions;
|
||||
@@ -170,6 +171,10 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<CompanyPreferences> CompanyPreferences =>
|
||||
_companyPreferences ??= new Repository<CompanyPreferences>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="CompanySmsAgreement"/> audit records. Tenant-filtered; never soft-deleted — legal audit trail.</summary>
|
||||
public IRepository<CompanySmsAgreement> CompanySmsAgreements =>
|
||||
_companySmsAgreements ??= new Repository<CompanySmsAgreement>(_context);
|
||||
|
||||
// AI Predictions
|
||||
/// <summary>Repository for <see cref="AiItemPrediction"/> records; tenant-filtered. Shared between QuoteItem and JobItem via a single nullable FK — no duplication on quote→job conversion.</summary>
|
||||
public IRepository<AiItemPrediction> AiItemPredictions =>
|
||||
|
||||
@@ -310,48 +310,6 @@ public class NotificationService : INotificationService
|
||||
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
|
||||
}
|
||||
|
||||
// SMS only for READY_FOR_PICKUP
|
||||
var smsFeatureEnabled = string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (smsFeatureEnabled && newStatusCode == "READY_FOR_PICKUP")
|
||||
{
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
|
||||
{
|
||||
var smsValues = new Dictionary<string, string>
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["customerName"] = customerName,
|
||||
["jobNumber"] = job.JobNumber ?? string.Empty
|
||||
};
|
||||
|
||||
var smsMessage = await GetRenderedSmsAsync(
|
||||
job.CompanyId, NotificationType.JobReadyForPickup, smsValues,
|
||||
$"{companyName}: Job {job.JobNumber} is ready for pickup! Reply STOP to opt out.");
|
||||
|
||||
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
|
||||
|
||||
await WriteLog(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Sms,
|
||||
NotificationType = NotificationType.JobReadyForPickup,
|
||||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = customerName,
|
||||
Recipient = smsPhone,
|
||||
Message = smsMessage,
|
||||
ErrorMessage = error,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CustomerId = customer.Id,
|
||||
JobId = job.Id,
|
||||
CompanyId = job.CompanyId
|
||||
});
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(customer.MobilePhone ?? customer.Phone))
|
||||
{
|
||||
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobReadyForPickup,
|
||||
customerName, customer.MobilePhone ?? customer.Phone!, job.CompanyId,
|
||||
customerId: customer.Id, jobId: job.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -364,7 +322,7 @@ public class NotificationService : INotificationService
|
||||
/// because the completion message may include a link to pay the invoice online, and uses a
|
||||
/// dedicated JobCompleted template with different wording than a mid-workflow status change.
|
||||
/// </summary>
|
||||
public async Task NotifyJobCompletedAsync(Job job)
|
||||
public async Task NotifyJobCompletedAsync(Job job, bool suppressSms = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -419,45 +377,48 @@ public class NotificationService : INotificationService
|
||||
customerName, customer.Email, job.CompanyId, customerId: customer.Id, jobId: job.Id));
|
||||
}
|
||||
|
||||
// SMS
|
||||
var smsOn = string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (smsOn)
|
||||
// SMS — skip when the caller (Admin/Manager) will handle it via the compose modal
|
||||
if (!suppressSms)
|
||||
{
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
|
||||
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
|
||||
if (smsAllowed)
|
||||
{
|
||||
var smsValues = new Dictionary<string, string>
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["customerName"] = customerName,
|
||||
["jobNumber"] = job.JobNumber ?? string.Empty
|
||||
};
|
||||
var smsValues = new Dictionary<string, string>
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["customerName"] = customerName,
|
||||
["jobNumber"] = job.JobNumber ?? string.Empty
|
||||
};
|
||||
|
||||
var smsMessage = await GetRenderedSmsAsync(
|
||||
job.CompanyId, NotificationType.JobCompleted, smsValues,
|
||||
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
|
||||
var smsMessage = await GetRenderedSmsAsync(
|
||||
job.CompanyId, NotificationType.JobCompleted, smsValues,
|
||||
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
|
||||
|
||||
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
|
||||
var (success, error) = await _smsService.SendSmsAsync(smsPhone, smsMessage);
|
||||
|
||||
await WriteLog(new NotificationLog
|
||||
await WriteLog(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Sms,
|
||||
NotificationType = NotificationType.JobCompleted,
|
||||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = customerName,
|
||||
Recipient = smsPhone,
|
||||
Message = smsMessage,
|
||||
ErrorMessage = error,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CustomerId = customer.Id,
|
||||
JobId = job.Id,
|
||||
CompanyId = job.CompanyId
|
||||
});
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(smsPhone))
|
||||
{
|
||||
Channel = NotificationChannel.Sms,
|
||||
NotificationType = NotificationType.JobCompleted,
|
||||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = customerName,
|
||||
Recipient = smsPhone,
|
||||
Message = smsMessage,
|
||||
ErrorMessage = error,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CustomerId = customer.Id,
|
||||
JobId = job.Id,
|
||||
CompanyId = job.CompanyId
|
||||
});
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(smsPhone))
|
||||
{
|
||||
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobCompleted,
|
||||
customerName, smsPhone, job.CompanyId, customerId: customer.Id, jobId: job.Id));
|
||||
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.JobCompleted,
|
||||
customerName, smsPhone, job.CompanyId, customerId: customer.Id, jobId: job.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,6 +428,90 @@ public class NotificationService : INotificationService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the job-completed SMS text without sending it, for admin review before manual send.
|
||||
/// Returns null when SMS is not gated on for the company or the customer has not opted in to SMS.
|
||||
/// </summary>
|
||||
public async Task<string?> RenderJobCompletedSmsAsync(Job job)
|
||||
{
|
||||
try
|
||||
{
|
||||
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
|
||||
if (customer == null) return null;
|
||||
|
||||
var (companyName, company) = await GetCompanyAsync(job.CompanyId);
|
||||
if (!await IsSmsAllowedForCompanyAsync(company)) return null;
|
||||
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (!customer.NotifyBySms || string.IsNullOrWhiteSpace(smsPhone)) return null;
|
||||
|
||||
var customerName = GetCustomerDisplayName(customer);
|
||||
var smsValues = new Dictionary<string, string>
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["customerName"] = customerName,
|
||||
["jobNumber"] = job.JobNumber ?? string.Empty
|
||||
};
|
||||
|
||||
return await GetRenderedSmsAsync(
|
||||
job.CompanyId, NotificationType.JobCompleted, smsValues,
|
||||
$"{companyName}: Job {job.JobNumber} is done and ready for pickup! Reply STOP to opt out.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "RenderJobCompletedSmsAsync failed for job {JobId}", job.Id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a manually-composed SMS for a job (Admin/Manager compose-before-send path).
|
||||
/// Appends opt-out instructions if not already present, then sends and logs the result.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string? Error)> SendJobSmsAsync(Job job, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var customer = job.Customer ?? await _context.Customers.FindAsync(job.CustomerId);
|
||||
if (customer == null) return (false, "Customer not found.");
|
||||
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (string.IsNullOrWhiteSpace(smsPhone)) return (false, "Customer has no phone number on file.");
|
||||
|
||||
var (_, company) = await GetCompanyAsync(job.CompanyId);
|
||||
if (!await IsSmsAllowedForCompanyAsync(company)) return (false, "SMS is not enabled for this company.");
|
||||
|
||||
// Ensure TCPA-required opt-out language is present
|
||||
const string stopSuffix = "Reply STOP to opt out.";
|
||||
if (!message.Contains("STOP", StringComparison.OrdinalIgnoreCase))
|
||||
message = message.TrimEnd() + " " + stopSuffix;
|
||||
|
||||
var (success, error) = await _smsService.SendSmsAsync(smsPhone, message);
|
||||
|
||||
await WriteLog(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Sms,
|
||||
NotificationType = NotificationType.JobCompleted,
|
||||
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = GetCustomerDisplayName(customer),
|
||||
Recipient = smsPhone,
|
||||
Message = message,
|
||||
ErrorMessage = error,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CustomerId = customer.Id,
|
||||
JobId = job.Id,
|
||||
CompanyId = job.CompanyId
|
||||
});
|
||||
|
||||
return (success, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SendJobSmsAsync failed for job {JobId}", job.Id);
|
||||
return (false, "An unexpected error occurred while sending the SMS.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the invoice to the customer with the PDF attached. When Stripe Connect is enabled for
|
||||
/// the company, includes a "Pay Online" button linking to the Stripe-hosted payment page
|
||||
@@ -801,13 +846,13 @@ public class NotificationService : INotificationService
|
||||
/// </summary>
|
||||
public async Task NotifySmsConsentGrantedAsync(Customer customer)
|
||||
{
|
||||
if (!string.Equals(await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled), "true", StringComparison.OrdinalIgnoreCase)) return;
|
||||
try
|
||||
{
|
||||
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||
if (string.IsNullOrWhiteSpace(smsPhone)) return;
|
||||
|
||||
var (companyName, _) = await GetCompanyAsync(customer.CompanyId);
|
||||
var (companyName, company) = await GetCompanyAsync(customer.CompanyId);
|
||||
if (!await IsSmsAllowedForCompanyAsync(company)) return;
|
||||
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
@@ -971,10 +1016,6 @@ public class NotificationService : INotificationService
|
||||
"Job {{jobNumber}} Ready for Pickup — {{companyName}}",
|
||||
"<p>Dear {{customerName}},</p><p>Your job <strong>{{jobNumber}}</strong> is ready for pickup!</p><p>Thank you for choosing {{companyName}}.</p>"
|
||||
),
|
||||
[(NotificationType.JobReadyForPickup, NotificationChannel.Sms)] = (
|
||||
null,
|
||||
"{{companyName}}: Job {{jobNumber}} is ready for pickup! Reply STOP to opt out."
|
||||
),
|
||||
[(NotificationType.JobCompleted, NotificationChannel.Email)] = (
|
||||
"Job {{jobNumber}} Complete — {{companyName}}",
|
||||
"<p>Dear {{customerName}},</p><p>Your job <strong>{{jobNumber}}</strong> is complete. Final price: <strong>{{finalPrice}}</strong>. It is now ready for pickup.</p><p>Thank you for choosing {{companyName}}.</p>"
|
||||
@@ -1121,6 +1162,28 @@ public class NotificationService : INotificationService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true only when all four gating tiers allow SMS for this company:
|
||||
/// platform kill-switch on → not SuperAdmin-disabled → plan AllowSms → company opted in.
|
||||
/// </summary>
|
||||
private async Task<bool> IsSmsAllowedForCompanyAsync(Company? company)
|
||||
{
|
||||
var platformOn = string.Equals(
|
||||
await _platformSettings.GetAsync(PlatformSettingKeys.SmsEnabled),
|
||||
"true", StringComparison.OrdinalIgnoreCase);
|
||||
if (!platformOn) return false;
|
||||
|
||||
if (company == null) return false;
|
||||
if (company.SmsDisabledByAdmin) return false;
|
||||
|
||||
var planConfig = await _context.SubscriptionPlanConfigs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||
if (planConfig?.AllowSms != true) return false;
|
||||
|
||||
return company.SmsEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the company entity and its display name. Returns a safe fallback name
|
||||
/// so templates never render blank company names even if the row is missing.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using Twilio;
|
||||
@@ -10,16 +12,24 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public class SmsService : ISmsService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly ILogger<SmsService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="SmsService"/>. Twilio credentials are read
|
||||
/// per call (not cached here) because they may be rotated without restarting the application,
|
||||
/// and Twilio's client initialization (<c>TwilioClient.Init</c>) is idempotent and cheap.
|
||||
/// <para>
|
||||
/// In non-production environments, all outbound messages are redirected to
|
||||
/// <c>Twilio:DevRedirectPhone</c> (if configured) so real customer numbers are never
|
||||
/// texted during development or staging. The original destination is prepended to the
|
||||
/// message body for traceability.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public SmsService(IConfiguration configuration, ILogger<SmsService> logger)
|
||||
public SmsService(IConfiguration configuration, IWebHostEnvironment env, ILogger<SmsService> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_env = env;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -58,6 +68,21 @@ public class SmsService : ISmsService
|
||||
return (false, "Invalid phone number");
|
||||
}
|
||||
|
||||
// Non-production guard: redirect all messages to the dev phone so real customers
|
||||
// are never texted outside of production. Double-gated on environment name AND
|
||||
// the config value so a misconfigured prod deploy can't accidentally redirect.
|
||||
var devRedirect = _configuration["Twilio:DevRedirectPhone"];
|
||||
if (!_env.IsProduction() && !string.IsNullOrWhiteSpace(devRedirect))
|
||||
{
|
||||
var devPhone = NormalizePhone(devRedirect);
|
||||
if (!string.IsNullOrEmpty(devPhone))
|
||||
{
|
||||
_logger.LogWarning("Non-production environment: redirecting SMS from {Original} to dev number {Dev}", normalizedPhone, devPhone);
|
||||
message = $"[DEV → {normalizedPhone}] {message}";
|
||||
normalizedPhone = devPhone;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
TwilioClient.Init(accountSid, authToken);
|
||||
|
||||
@@ -7,6 +7,12 @@ public static class AppConstants
|
||||
|
||||
/// <summary>Set to true to enable SMS features throughout the UI.</summary>
|
||||
public const bool SmsEnabled = false;
|
||||
|
||||
/// <summary>
|
||||
/// Current version of the SMS terms of service. Incrementing this string invalidates all
|
||||
/// existing company agreements and forces company admins to re-accept before enabling SMS.
|
||||
/// </summary>
|
||||
public const string SmsTermsVersion = "1.0";
|
||||
|
||||
public static class Roles
|
||||
{
|
||||
|
||||
@@ -126,9 +126,15 @@ public class CompanySettingsController : Controller
|
||||
|
||||
var dto = _mapper.Map<CompanySettingsDto>(company);
|
||||
|
||||
// Populate AllowOnlinePayments from subscription plan config
|
||||
// Populate plan-gated feature flags
|
||||
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
|
||||
dto.AllowSms = planConfig?.AllowSms ?? false;
|
||||
dto.SmsEnabled = company.SmsEnabled;
|
||||
dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin;
|
||||
dto.SmsTermsVersion = AppConstants.SmsTermsVersion;
|
||||
dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements
|
||||
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
|
||||
|
||||
// Flag whether Stripe Connect is configured (non-placeholder client ID)
|
||||
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
|
||||
@@ -619,6 +625,74 @@ public class CompanySettingsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the company-level SMS opt-in flag. When enabling and no current-version agreement
|
||||
/// exists, the request must include AgreedToTerms=true and a matching TermsVersion — the
|
||||
/// acceptance is then recorded as a <see cref="CompanySmsAgreement"/> audit row.
|
||||
/// Disabling never requires agreement.
|
||||
/// </summary>
|
||||
// POST: CompanySettings/UpdateSmsPreferences
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> UpdateSmsPreferences([FromBody] UpdateSmsPreferencesDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
return Json(new { success = false, message = "User does not have a company ID." });
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
||||
if (company == null)
|
||||
return Json(new { success = false, message = "Company not found." });
|
||||
|
||||
if (dto.SmsEnabled)
|
||||
{
|
||||
var hasAgreement = await _unitOfWork.CompanySmsAgreements
|
||||
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
|
||||
|
||||
if (!hasAgreement)
|
||||
{
|
||||
// Require explicit acceptance of the current terms version
|
||||
if (!dto.AgreedToTerms || dto.TermsVersion != AppConstants.SmsTermsVersion)
|
||||
return Json(new { success = false, requiresAgreement = true, message = "You must accept the SMS terms of service to enable SMS notifications." });
|
||||
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
|
||||
var userName = User.Identity?.Name ?? string.Empty;
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var ua = Request.Headers.UserAgent.ToString();
|
||||
|
||||
var agreement = new CompanySmsAgreement
|
||||
{
|
||||
CompanyId = companyId.Value,
|
||||
AgreedByUserId = userId,
|
||||
AgreedByUserName = userName,
|
||||
AgreedAt = DateTime.UtcNow,
|
||||
IpAddress = ip,
|
||||
UserAgent = ua,
|
||||
TermsVersion = AppConstants.SmsTermsVersion,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.CompanySmsAgreements.AddAsync(agreement);
|
||||
_logger.LogInformation("Company {CompanyId} accepted SMS terms v{Version} by user {UserId} from {Ip}",
|
||||
companyId, AppConstants.SmsTermsVersion, userId, ip);
|
||||
}
|
||||
}
|
||||
|
||||
company.SmsEnabled = dto.SmsEnabled;
|
||||
await _unitOfWork.Companies.UpdateAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Company {CompanyId} SMS opt-in set to {SmsEnabled}", companyId, dto.SmsEnabled);
|
||||
return Json(new { success = true, message = dto.SmsEnabled ? "SMS notifications enabled." : "SMS notifications disabled." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating SMS preferences");
|
||||
return Json(new { success = false, message = "An error occurred while saving SMS preferences." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a suggested AI profile draft from existing company configuration — company name/location,
|
||||
/// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and
|
||||
|
||||
@@ -501,6 +501,14 @@ public class JobsController : Controller
|
||||
ViewBag.CanResyncFromQuote = preProductionCodes.Contains(job.JobStatus?.StatusCode ?? "");
|
||||
}
|
||||
|
||||
// SMS compose modal: pass pending preview (set by CompleteJob for Admin/Manager) and role flags
|
||||
if (TempData["PendingSmsPreview"] is string smsPreview)
|
||||
ViewBag.PendingSmsPreview = smsPreview;
|
||||
|
||||
var detailsCompanyRole = User.FindFirst("CompanyRole")?.Value ?? string.Empty;
|
||||
ViewBag.IsAdminOrManager = detailsCompanyRole is "CompanyAdmin" or "Administrator" or "Manager";
|
||||
ViewBag.SmsEnabled = HttpContext.Items["AllowSms"] is true;
|
||||
|
||||
var jobPrefs = await GetCompanyPreferencesAsync(job.CompanyId);
|
||||
if (guidedActivation == AppConstants.GuidedActivation.JobCreatedStep
|
||||
&& jobPrefs?.FirstWorkflowCompleted == false)
|
||||
@@ -2751,14 +2759,20 @@ public class JobsController : Controller
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Admin/Manager gets an SMS compose modal; ShopFloor workers trigger auto-send.
|
||||
var companyRole = User.FindFirst("CompanyRole")?.Value ?? string.Empty;
|
||||
var isAdminOrManager = companyRole is "CompanyAdmin" or "Administrator" or "Manager";
|
||||
|
||||
// Load job with customer for notification + SMS render
|
||||
var jobForNotify = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId, false, j => j.Customer);
|
||||
|
||||
// Notify customer that job is completed (only if user opted in)
|
||||
if (dto.SendEmailToCustomer)
|
||||
if (dto.SendEmailToCustomer && jobForNotify != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jobForNotify = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId, false, j => j.Customer);
|
||||
if (jobForNotify != null)
|
||||
await _notificationService.NotifyJobCompletedAsync(jobForNotify);
|
||||
// Admin/Manager path: suppress auto-SMS so they can review via compose modal
|
||||
await _notificationService.NotifyJobCompletedAsync(jobForNotify, suppressSms: isAdminOrManager);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2769,6 +2783,21 @@ public class JobsController : Controller
|
||||
this.SetNotificationResultToast(completeNotifLog);
|
||||
}
|
||||
|
||||
// For Admin/Manager: render the SMS template and store it for the compose modal
|
||||
if (isAdminOrManager && jobForNotify != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var smsPreview = await _notificationService.RenderJobCompletedSmsAsync(jobForNotify);
|
||||
if (smsPreview != null)
|
||||
TempData["PendingSmsPreview"] = smsPreview;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SMS render failed for job {Id}", dto.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
await BroadcastJobUpdate(job.CompanyId, job.JobNumber!, job.Id, "Completed", "Job completed");
|
||||
|
||||
if (completedStatus != null)
|
||||
@@ -2797,6 +2826,46 @@ public class JobsController : Controller
|
||||
|
||||
#endregion
|
||||
|
||||
#region SMS Compose
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pre-rendered job-completed SMS text so the Admin/Manager compose modal
|
||||
/// can pre-fill the textarea when the "Send SMS" button is clicked from the job details page
|
||||
/// (as opposed to the auto-populated TempData path from CompleteJob).
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> RenderJobSms(int jobId)
|
||||
{
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId, false, j => j.Customer);
|
||||
if (job == null) return NotFound();
|
||||
|
||||
var text = await _notificationService.RenderJobCompletedSmsAsync(job);
|
||||
if (text == null)
|
||||
return Json(new { eligible = false, reason = "SMS is not enabled or customer has not opted in." });
|
||||
|
||||
return Json(new { eligible = true, message = text });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a manually-composed SMS for a job. Validates and auto-appends STOP language,
|
||||
/// sends via Twilio, and writes a NotificationLog entry.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SendJobSms([FromBody] SendJobSmsRequest request)
|
||||
{
|
||||
if (request.JobId <= 0 || string.IsNullOrWhiteSpace(request.Message))
|
||||
return Json(new { success = false, error = "Job ID and message are required." });
|
||||
|
||||
var job = await _unitOfWork.Jobs.GetByIdAsync(request.JobId, false, j => j.Customer);
|
||||
if (job == null) return Json(new { success = false, error = "Job not found." });
|
||||
|
||||
var (success, error) = await _notificationService.SendJobSmsAsync(job, request.Message.Trim());
|
||||
return Json(new { success, error });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edit Job Items (Wizard)
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -64,6 +64,7 @@ public class PlatformSubscriptionController : Controller
|
||||
AllowAiPhotoQuotes = c.AllowAiPhotoQuotes,
|
||||
AllowAiInventoryAssist = c.AllowAiInventoryAssist,
|
||||
AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck,
|
||||
AllowSms = c.AllowSms,
|
||||
IsActive = c.IsActive,
|
||||
SortOrder = c.SortOrder
|
||||
}).ToList();
|
||||
@@ -104,6 +105,7 @@ public class PlatformSubscriptionController : Controller
|
||||
AllowAiPhotoQuotes = config.AllowAiPhotoQuotes,
|
||||
AllowAiInventoryAssist = config.AllowAiInventoryAssist,
|
||||
AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck,
|
||||
AllowSms = config.AllowSms,
|
||||
IsActive = config.IsActive
|
||||
};
|
||||
|
||||
@@ -149,6 +151,7 @@ public class PlatformSubscriptionController : Controller
|
||||
config.AllowAiPhotoQuotes = dto.AllowAiPhotoQuotes;
|
||||
config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist;
|
||||
config.AllowAiCatalogPriceCheck = dto.AllowAiCatalogPriceCheck;
|
||||
config.AllowSms = dto.AllowSms;
|
||||
config.IsActive = dto.IsActive;
|
||||
|
||||
await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config);
|
||||
|
||||
@@ -91,6 +91,7 @@ public class SubscriptionMiddleware
|
||||
{
|
||||
context.Items["AllowOnlinePayments"] = true;
|
||||
context.Items["AllowAccounting"] = true;
|
||||
context.Items["AllowSms"] = true;
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
@@ -137,6 +138,9 @@ public class SubscriptionMiddleware
|
||||
|| (company.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false));
|
||||
context.Items["AllowAccounting"] = company.IsComped
|
||||
|| (company.AccountingOverride ?? (planConfig?.AllowAccounting ?? false));
|
||||
// SMS: comped gets it; admin force-disable beats everything else; then plan; then company opt-in
|
||||
context.Items["AllowSms"] = company.IsComped
|
||||
|| (!company.SmsDisabledByAdmin && (planConfig?.AllowSms ?? false) && company.SmsEnabled);
|
||||
|
||||
if (company.IsComped)
|
||||
{
|
||||
|
||||
@@ -149,6 +149,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="card-title mb-3 pb-2 border-bottom">SMS Override</h5>
|
||||
<p class="text-muted small mb-3">Use this to immediately cut off SMS for a company — for example if they are sending abusive messages or have a billing dispute. This overrides the plan entitlement and the company's own opt-in setting.</p>
|
||||
<div class="mb-4">
|
||||
<div class="form-check form-switch">
|
||||
<input asp-for="SmsDisabledByAdmin" class="form-check-input" type="checkbox" role="switch" id="SmsDisabledByAdmin" />
|
||||
<label asp-for="SmsDisabledByAdmin" class="form-check-label fw-medium text-danger">Force-disable SMS for this company</label>
|
||||
</div>
|
||||
<div class="form-text">When checked, no outbound SMS will be sent for this company regardless of their plan or own settings.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 justify-content-end">
|
||||
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
|
||||
@@ -1079,6 +1079,45 @@
|
||||
</a>
|
||||
</h5>
|
||||
<p class="text-muted">Control which events trigger email notifications and alert thresholds.</p>
|
||||
@if (ViewBag.SmsEnabled == true)
|
||||
{
|
||||
<div class="card border mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title fw-semibold"><i class="bi bi-phone me-1"></i> SMS Notifications</h6>
|
||||
@if (Model.SmsDisabledByAdmin)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent mb-3 py-2">
|
||||
<i class="bi bi-slash-circle me-1"></i>
|
||||
<strong>SMS has been disabled by an administrator.</strong> Contact support to re-enable.
|
||||
</div>
|
||||
}
|
||||
else if (!Model.AllowSms)
|
||||
{
|
||||
<div class="alert alert-info alert-permanent mb-3 py-2">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
SMS notifications are not included in your current plan. Upgrade to Pro or Enterprise to enable customer SMS alerts.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small mb-3">When enabled, customers who have given SMS consent will receive text alerts for job status changes (e.g. ready for pickup).</p>
|
||||
<div class="form-check form-switch" id="smsToggleWrap">
|
||||
<input class="form-check-input" type="checkbox" id="smsEnabledToggle" @(Model.SmsEnabled ? "checked" : "")
|
||||
data-has-agreement="@(Model.HasCurrentSmsAgreement ? "true" : "false")"
|
||||
data-terms-version="@Model.SmsTermsVersion">
|
||||
<label class="form-check-label fw-medium" for="smsEnabledToggle">Enable SMS Notifications</label>
|
||||
</div>
|
||||
@if (!Model.HasCurrentSmsAgreement && !Model.SmsEnabled)
|
||||
{
|
||||
<div class="form-text text-warning mt-1">
|
||||
<i class="bi bi-info-circle me-1"></i>You'll need to accept the SMS terms of service the first time you enable this.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form id="notificationsForm">
|
||||
<h6 class="border-bottom pb-2 mb-3">Email Sender</h6>
|
||||
<div class="row mb-3">
|
||||
@@ -1995,6 +2034,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMS Terms of Service Agreement Modal -->
|
||||
<div class="modal fade" id="smsTermsModal" tabindex="-1" aria-labelledby="smsTermsModalLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title" id="smsTermsModalLabel">
|
||||
<i class="bi bi-phone me-2"></i>SMS Notifications — Terms of Service
|
||||
</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning alert-permanent mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>Read carefully before enabling.</strong> Sending unsolicited text messages carries significant legal risk. By enabling SMS you are personally accepting responsibility for your company's compliance.
|
||||
</div>
|
||||
|
||||
<h6 class="fw-bold">1. Prior Express Written Consent Required</h6>
|
||||
<p class="text-muted small">You <strong>must obtain clear, documented consent</strong> from each customer before sending them any SMS message. This means each customer must have explicitly agreed — in writing or through a recorded digital interaction — that they wish to receive text messages from your business. Enabling this feature is not consent on their behalf. You must collect and record their authorization individually, before enabling SMS for their account in this system.</p>
|
||||
|
||||
<h6 class="fw-bold">2. Federal Law Governs SMS — Fines Are Real</h6>
|
||||
<p class="text-muted small">The <strong>Telephone Consumer Protection Act (TCPA)</strong>, enforced by the Federal Communications Commission (FCC), imposes fines of <strong>$500 to $1,500 per individual message</strong> sent without proper authorization. These fines apply per text, not per customer. A single campaign to 100 unconsented recipients could result in exposure of $50,000 to $150,000. The FCC and private plaintiffs both actively pursue TCPA violations.</p>
|
||||
|
||||
<h6 class="fw-bold">3. Opt-Out Requests Must Be Honored Immediately</h6>
|
||||
<p class="text-muted small">Any customer who replies <strong>STOP, UNSUBSCRIBE, CANCEL, END, or QUIT</strong> must be removed from all future SMS immediately. This system will process inbound opt-out replies automatically, but you must also honor any opt-out communicated by phone, email, or in person. Continuing to text a customer after an opt-out is a TCPA violation.</p>
|
||||
|
||||
<h6 class="fw-bold">4. Message Rates & Content Restrictions</h6>
|
||||
<p class="text-muted small">Every message sent must include your business name and an opt-out reminder (e.g., "Reply STOP to opt out"). Messages must be directly relevant to the service the customer consented to receive and must not contain solicitations, promotions, or third-party offers unless the customer has separately consented to those.</p>
|
||||
|
||||
<h6 class="fw-bold">5. Your Responsibility — Not Ours</h6>
|
||||
<p class="text-muted small">Powder Coating Logix provides this feature as a communication tool only. <strong>We are not responsible for how you use it.</strong> You agree that your company is solely responsible for obtaining proper consent, maintaining records of that consent, honoring opt-outs, and ensuring all outbound messages comply with the TCPA, FCC regulations, and any applicable state laws. You agree to indemnify and hold Powder Coating Logix harmless from any claims, fines, or damages arising from your company's use of SMS.</p>
|
||||
|
||||
<hr />
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="smsTermsAgreementCheck">
|
||||
<label class="form-check-label fw-semibold" for="smsTermsAgreementCheck">
|
||||
I have read and understood the above terms. I confirm that my company will obtain proper customer consent before enabling SMS for any individual customer, and I accept full responsibility for our compliance with all applicable laws.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" id="smsTermsDeclineBtn">Cancel — Keep SMS Disabled</button>
|
||||
<button type="button" class="btn btn-primary" id="smsTermsAcceptBtn" disabled>
|
||||
<i class="bi bi-check-circle me-1"></i>I Agree & Enable SMS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
@@ -2415,6 +2502,83 @@
|
||||
};
|
||||
}, 'Save Retention Policy');
|
||||
|
||||
// SMS toggle — shows terms modal on first enable (or after terms version change)
|
||||
(function () {
|
||||
const toggle = document.getElementById('smsEnabledToggle');
|
||||
if (!toggle) return;
|
||||
|
||||
const smsTermsModal = new bootstrap.Modal(document.getElementById('smsTermsModal'));
|
||||
const acceptBtn = document.getElementById('smsTermsAcceptBtn');
|
||||
const declineBtn = document.getElementById('smsTermsDeclineBtn');
|
||||
const agreeCheck = document.getElementById('smsTermsAgreementCheck');
|
||||
|
||||
// Unlock the accept button only when checkbox is ticked
|
||||
agreeCheck.addEventListener('change', function () {
|
||||
acceptBtn.disabled = !this.checked;
|
||||
});
|
||||
|
||||
function postSmsPreference(enabled, agreedToTerms, termsVersion) {
|
||||
$.ajax({
|
||||
url: '@Url.Action("UpdateSmsPreferences", "CompanySettings")',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ SmsEnabled: enabled, AgreedToTerms: agreedToTerms, TermsVersion: termsVersion }),
|
||||
headers: { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').first().val() },
|
||||
success: function (res) {
|
||||
if (res.success) {
|
||||
toggle.dataset.hasAgreement = 'true';
|
||||
showToast('success', res.message);
|
||||
} else {
|
||||
// Revert toggle on failure
|
||||
toggle.checked = !enabled;
|
||||
showToast('error', res.message || 'Failed to save SMS preference.');
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
toggle.checked = !enabled;
|
||||
showToast('error', 'Failed to save SMS preference.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggle.addEventListener('change', function () {
|
||||
const enabled = this.checked;
|
||||
const hasAgreement = this.dataset.hasAgreement === 'true';
|
||||
const termsVersion = this.dataset.termsVersion;
|
||||
|
||||
if (!enabled) {
|
||||
// Disabling: no agreement needed
|
||||
postSmsPreference(false, false, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAgreement) {
|
||||
// Re-enabling: already agreed to this version
|
||||
postSmsPreference(true, false, null);
|
||||
return;
|
||||
}
|
||||
|
||||
// First enable (or terms version changed): show the modal
|
||||
agreeCheck.checked = false;
|
||||
acceptBtn.disabled = true;
|
||||
smsTermsModal.show();
|
||||
|
||||
// Revert toggle until they explicitly agree
|
||||
toggle.checked = false;
|
||||
|
||||
acceptBtn.onclick = function () {
|
||||
smsTermsModal.hide();
|
||||
toggle.checked = true;
|
||||
postSmsPreference(true, true, termsVersion);
|
||||
};
|
||||
|
||||
declineBtn.onclick = function () {
|
||||
smsTermsModal.hide();
|
||||
toggle.checked = false;
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
// Toast helper function (exposed globally for lookup management)
|
||||
window.showToast = function(type, message) {
|
||||
if (type === 'success') {
|
||||
|
||||
@@ -1346,6 +1346,14 @@
|
||||
<i class="bi bi-check-circle me-2"></i>Complete Job
|
||||
</button>
|
||||
}
|
||||
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false) && Model.CustomerNotifyBySms && !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone))
|
||||
{
|
||||
<button type="button" class="btn btn-outline-info" id="btnSendSms"
|
||||
data-job-id="@Model.Id"
|
||||
title="Send a custom SMS to @Model.CustomerName">
|
||||
<i class="bi bi-chat-dots me-2"></i>Send SMS
|
||||
</button>
|
||||
}
|
||||
<a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger">
|
||||
<i class="bi bi-trash me-2"></i>Delete Job
|
||||
</a>
|
||||
@@ -1819,6 +1827,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMS Compose Modal (Admin/Manager only) -->
|
||||
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false))
|
||||
{
|
||||
<div class="modal fade" id="smsComposeModal" tabindex="-1" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-info bg-opacity-10">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-chat-dots me-2 text-info"></i>Send SMS to @Model.CustomerName
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" id="smsModalClose"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.CustomerMobilePhone))
|
||||
{
|
||||
<p class="text-muted small mb-3">
|
||||
<i class="bi bi-phone me-1"></i>Sending to: <strong>@Model.CustomerMobilePhone</strong>
|
||||
</p>
|
||||
}
|
||||
<div class="mb-2">
|
||||
<label class="form-label fw-semibold" for="smsMessageText">Message</label>
|
||||
<textarea class="form-control" id="smsMessageText" rows="5"
|
||||
placeholder="Type your message…" maxlength="160"></textarea>
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<div id="smsStopWarning" class="text-warning small d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically.
|
||||
</div>
|
||||
<div class="ms-auto text-muted small"><span id="smsCharCount">0</span> / 160</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="smsSendError" class="alert alert-danger d-none mt-2"></div>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
|
||||
Skip — don't send
|
||||
</button>
|
||||
<button type="button" class="btn btn-info text-white" id="smsSendBtn">
|
||||
<i class="bi bi-send me-1"></i>Send SMS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Hidden form used by item-wizard.js to collect item data and submit to UpdateItems -->
|
||||
<form asp-action="UpdateItems" asp-controller="Jobs" method="post" id="jobItemsForm" style="display:none">
|
||||
@Html.AntiForgeryToken()
|
||||
@@ -2967,6 +3020,23 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@if ((bool)(ViewBag.IsAdminOrManager ?? false) && (bool)(ViewBag.SmsEnabled ?? false))
|
||||
{
|
||||
<script src="~/js/jobs-sms-compose.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
(() => {
|
||||
const pendingPreview = @Html.Raw(ViewBag.PendingSmsPreview != null
|
||||
? System.Text.Json.JsonSerializer.Serialize((string)ViewBag.PendingSmsPreview)
|
||||
: "null");
|
||||
const jobIdForSms = @Model.Id;
|
||||
const renderUrl = '@Url.Action("RenderJobSms", "Jobs")';
|
||||
const sendUrl = '@Url.Action("SendJobSms", "Jobs")';
|
||||
const customerOptedIn = @(Model.CustomerNotifyBySms ? "true" : "false");
|
||||
window.__smsCompose = { pendingPreview, jobIdForSms, renderUrl, sendUrl, customerOptedIn };
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Save as Template Modal -->
|
||||
|
||||
@@ -132,6 +132,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mb-3 pb-2 border-bottom mt-4">SMS Notifications</h5>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check form-switch">
|
||||
<input asp-for="AllowSms" class="form-check-input" type="checkbox" role="switch" />
|
||||
<label asp-for="AllowSms" class="form-check-label fw-medium">Allow SMS Notifications</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
When enabled, companies on this plan can send SMS job-status notifications to customers
|
||||
(subject to the platform SMS kill-switch and the company's own opt-in setting).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mb-3 pb-2 border-bottom mt-4">AI Features</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -169,6 +169,19 @@
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">SMS Notifications</td>
|
||||
<td>
|
||||
@if (plan.AllowSms)
|
||||
{
|
||||
<span class="badge bg-success">Enabled</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Disabled</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="table-light">
|
||||
<td colspan="2" class="fw-semibold small text-uppercase text-muted py-1">Stripe</td>
|
||||
</tr>
|
||||
|
||||
@@ -48,9 +48,10 @@
|
||||
"FromName": "Powder Coating App Staff"
|
||||
},
|
||||
"Twilio": {
|
||||
"AccountSid": "your-account-sid-here",
|
||||
"AuthToken": "your-auth-token-here",
|
||||
"FromNumber": "+1XXXXXXXXXX"
|
||||
"AccountSid": "SK45bb87a7645d34c9227ea20faccad642",
|
||||
"AuthToken": " f262409674753f285b1c8184785c270e",
|
||||
"FromNumber": "+18664883595",
|
||||
"DevRedirectPhone": ""
|
||||
},
|
||||
"Stripe": {
|
||||
"SecretKey": "sk_test_51TM6ukDedBup3CZU5TLbLhbBZDuBYwwygoc58JZwIrYQ2SyhKPUFcbHkFV1Q5osqkIEPt3XkwgsuinSJbY58GLx300l7z2lvqg",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* SMS compose modal for Job Details — Admin/Manager path.
|
||||
*
|
||||
* Entry points:
|
||||
* - Auto-opens after CompleteJob when TempData contains a pending preview.
|
||||
* - Opened manually via the "Send SMS" button; fetches a fresh template first.
|
||||
*
|
||||
* Requires window.__smsCompose to be set by the inline Razor script before this file loads.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const MODAL_ID = 'smsComposeModal';
|
||||
const TEXTAREA = 'smsMessageText';
|
||||
const CHAR_COUNT = 'smsCharCount';
|
||||
const STOP_WARN = 'smsStopWarning';
|
||||
const SEND_BTN = 'smsSendBtn';
|
||||
const ERROR_DIV = 'smsSendError';
|
||||
const MAX_CHARS = 160;
|
||||
const STOP_TOKEN = 'STOP';
|
||||
|
||||
function antiForgeryToken() {
|
||||
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
}
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
function openModal(prefilledText) {
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(el(MODAL_ID));
|
||||
const textarea = el(TEXTAREA);
|
||||
if (textarea) textarea.value = prefilledText ?? '';
|
||||
updateCharCount();
|
||||
el(ERROR_DIV)?.classList.add('d-none');
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function updateCharCount() {
|
||||
const textarea = el(TEXTAREA);
|
||||
if (!textarea) return;
|
||||
const text = textarea.value;
|
||||
const len = text.length;
|
||||
const charEl = el(CHAR_COUNT);
|
||||
const warnEl = el(STOP_WARN);
|
||||
if (charEl) charEl.textContent = len;
|
||||
if (warnEl) {
|
||||
const hasStop = text.toUpperCase().includes(STOP_TOKEN);
|
||||
warnEl.classList.toggle('d-none', hasStop);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const div = el(ERROR_DIV);
|
||||
if (!div) return;
|
||||
div.textContent = msg;
|
||||
div.classList.remove('d-none');
|
||||
}
|
||||
|
||||
async function sendSms() {
|
||||
const cfg = window.__smsCompose ?? {};
|
||||
const btn = el(SEND_BTN);
|
||||
let message = el(TEXTAREA)?.value?.trim() ?? '';
|
||||
|
||||
if (!message) { showError('Please enter a message.'); return; }
|
||||
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sending…'; }
|
||||
el(ERROR_DIV)?.classList.add('d-none');
|
||||
|
||||
try {
|
||||
const resp = await fetch(cfg.sendUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': antiForgeryToken()
|
||||
},
|
||||
body: JSON.stringify({ jobId: cfg.jobIdForSms, message })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(el(MODAL_ID))?.hide();
|
||||
// Show a quick success toast using existing toastSuccess pattern if available
|
||||
const toastEl = document.getElementById('successToast');
|
||||
const toastMsg = document.getElementById('successToastMessage');
|
||||
if (toastEl && toastMsg) {
|
||||
toastMsg.textContent = 'SMS sent successfully.';
|
||||
bootstrap.Toast.getOrCreateInstance(toastEl).show();
|
||||
}
|
||||
} else {
|
||||
showError(data.error ?? 'Failed to send SMS. Please try again.');
|
||||
}
|
||||
} catch {
|
||||
showError('A network error occurred. Please check your connection and try again.');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-send me-1"></i>Send SMS'; }
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const cfg = window.__smsCompose ?? {};
|
||||
|
||||
// Character counter
|
||||
el(TEXTAREA)?.addEventListener('input', updateCharCount);
|
||||
|
||||
// Send button
|
||||
el(SEND_BTN)?.addEventListener('click', sendSms);
|
||||
|
||||
// "Send SMS" button on the job details action bar
|
||||
document.getElementById('btnSendSms')?.addEventListener('click', async () => {
|
||||
if (!cfg.customerOptedIn) {
|
||||
alert('This customer has not opted in to SMS notifications.');
|
||||
return;
|
||||
}
|
||||
// Fetch a fresh render of the template
|
||||
try {
|
||||
const resp = await fetch(`${cfg.renderUrl}?jobId=${cfg.jobIdForSms}`);
|
||||
const data = await resp.json();
|
||||
openModal(data.eligible ? data.message : '');
|
||||
} catch {
|
||||
openModal('');
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-open after CompleteJob if server passed a pending preview
|
||||
if (cfg.pendingPreview) {
|
||||
// Small delay so the page has fully rendered
|
||||
setTimeout(() => openModal(cfg.pendingPreview), 400);
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user