feat: Implement provisioning pipelines for subscription management

- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
This commit is contained in:
Matt Batchelder
2026-03-18 10:27:26 -04:00
parent c2e03de8bb
commit c6d46098dd
77 changed files with 9412 additions and 29 deletions

View File

@@ -0,0 +1,173 @@
using Microsoft.Extensions.Options;
using SendGrid;
using SendGrid.Helpers.Mail;
namespace OTSSignsOrchestrator.Server.Services;
public class EmailOptions
{
public const string Section = "Email";
public string SendGridApiKey { get; set; } = string.Empty;
public string SenderEmail { get; set; } = "noreply@otssigns.com";
public string SenderName { get; set; } = "OTS Signs";
}
/// <summary>
/// Email service backed by SendGrid. All methods catch exceptions, log as Error,
/// and return bool success — they never throw.
/// </summary>
public class EmailService
{
private readonly ILogger<EmailService> _logger;
private readonly EmailOptions _options;
private readonly SendGridClient? _client;
public EmailService(ILogger<EmailService> logger, IOptions<EmailOptions> options)
{
_logger = logger;
_options = options.Value;
if (!string.IsNullOrWhiteSpace(_options.SendGridApiKey))
{
_client = new SendGridClient(_options.SendGridApiKey);
}
else
{
_logger.LogWarning("SendGrid API key not configured — emails will be logged only");
}
}
public async Task<bool> SendWelcomeEmailAsync(
string toEmail, string firstName, string instanceUrl, string invitationLink)
{
var subject = "Welcome to OTS Signs — Your CMS is Ready!";
var html = $"""
<h2>Welcome to OTS Signs, {HtmlEncode(firstName)}!</h2>
<p>Your Xibo CMS instance has been provisioned and is ready to use.</p>
<p><strong>Instance URL:</strong> <a href="{HtmlEncode(instanceUrl)}">{HtmlEncode(instanceUrl)}</a></p>
<p><strong>Accept your invitation to get started:</strong></p>
<p><a href="{HtmlEncode(invitationLink)}" style="display:inline-block;padding:12px 24px;background-color:#3B82F6;color:#fff;text-decoration:none;border-radius:6px;">Accept Invitation</a></p>
<p>If you have any questions, please contact our support team.</p>
<p> The OTS Signs Team</p>
""";
return await SendEmailAsync(toEmail, subject, html);
}
public async Task<bool> SendPaymentFailedEmailAsync(
string toEmail, string companyName, int failedCount)
{
var daysSinceFirst = failedCount * 3; // approximate
string subject;
string urgency;
if (daysSinceFirst >= 21)
{
subject = $"FINAL NOTICE — Payment Required for {companyName}";
urgency = "<p style=\"color:#DC2626;font-weight:bold;\">⚠️ FINAL NOTICE: Your service will be suspended if payment is not received immediately.</p>";
}
else if (daysSinceFirst >= 7)
{
subject = $"URGENT — Payment Failed for {companyName}";
urgency = "<p style=\"color:#F59E0B;font-weight:bold;\">⚠️ URGENT: Please update your payment method to avoid service interruption.</p>";
}
else
{
subject = $"Payment Failed for {companyName}";
urgency = "<p>Please update your payment method at your earliest convenience.</p>";
}
var html = $"""
<h2>Payment Failed — {HtmlEncode(companyName)}</h2>
{urgency}
<p>We were unable to process your payment (attempt #{failedCount}).</p>
<p>Please update your payment method to keep your OTS Signs service active.</p>
<p>— The OTS Signs Team</p>
""";
return await SendEmailAsync(toEmail, subject, html);
}
public async Task<bool> SendTrialEndingEmailAsync(
string toEmail, string firstName, DateTime trialEndDate)
{
var daysLeft = (int)Math.Ceiling((trialEndDate - DateTime.UtcNow).TotalDays);
var subject = $"Your OTS Signs Trial Ends in {Math.Max(0, daysLeft)} Day{(daysLeft != 1 ? "s" : "")}";
var html = $"""
<h2>Hi {HtmlEncode(firstName)},</h2>
<p>Your OTS Signs trial ends on <strong>{trialEndDate:MMMM dd, yyyy}</strong>.</p>
<p>To continue using your CMS without interruption, please subscribe before your trial expires.</p>
<p>— The OTS Signs Team</p>
""";
return await SendEmailAsync(toEmail, subject, html);
}
public async Task<bool> SendReportEmailAsync(
string toEmail, string attachmentName, byte[] attachment, string mimeType)
{
var subject = $"OTS Signs Report — {attachmentName}";
var html = $"""
<h2>OTS Signs Report</h2>
<p>Please find the attached report: <strong>{HtmlEncode(attachmentName)}</strong></p>
<p>This report was generated automatically by the OTS Signs Orchestrator.</p>
<p>— The OTS Signs Team</p>
""";
return await SendEmailAsync(toEmail, subject, html, attachmentName, attachment, mimeType);
}
private async Task<bool> SendEmailAsync(
string toEmail,
string subject,
string htmlContent,
string? attachmentName = null,
byte[]? attachmentData = null,
string? attachmentMimeType = null)
{
try
{
if (_client is null)
{
_logger.LogWarning(
"SendGrid not configured — would send email to {Email}: {Subject}",
toEmail, subject);
return true; // Not a failure — just not configured
}
var from = new EmailAddress(_options.SenderEmail, _options.SenderName);
var to = new EmailAddress(toEmail);
var msg = MailHelper.CreateSingleEmail(from, to, subject, null, htmlContent);
if (attachmentData is not null && attachmentName is not null)
{
msg.AddAttachment(
attachmentName,
Convert.ToBase64String(attachmentData),
attachmentMimeType ?? "application/octet-stream");
}
var response = await _client.SendEmailAsync(msg);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Email sent to {Email}: {Subject}", toEmail, subject);
return true;
}
var body = await response.Body.ReadAsStringAsync();
_logger.LogError(
"SendGrid returned {StatusCode} for email to {Email}: {Body}",
response.StatusCode, toEmail, body);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {Email}: {Subject}", toEmail, subject);
return false;
}
}
private static string HtmlEncode(string value) =>
System.Net.WebUtility.HtmlEncode(value);
}