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:
78
OTSSignsOrchestrator.Server/Services/AbbreviationService.cs
Normal file
78
OTSSignsOrchestrator.Server/Services/AbbreviationService.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Server.Data;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Services;
|
||||
|
||||
public class AbbreviationService
|
||||
{
|
||||
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"inc", "llc", "ltd", "co", "corp", "group", "signs", "digital",
|
||||
"media", "the", "and", "of", "a"
|
||||
};
|
||||
|
||||
private readonly OrchestratorDbContext _db;
|
||||
private readonly ILogger<AbbreviationService> _logger;
|
||||
|
||||
public AbbreviationService(OrchestratorDbContext db, ILogger<AbbreviationService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAsync(string companyName)
|
||||
{
|
||||
var words = Regex.Split(companyName.Trim(), @"\s+")
|
||||
.Select(w => Regex.Replace(w, @"[^a-zA-Z0-9]", ""))
|
||||
.Where(w => w.Length > 0 && !StopWords.Contains(w))
|
||||
.ToList();
|
||||
|
||||
if (words.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot generate abbreviation from company name '{companyName}' — no usable words after filtering.");
|
||||
|
||||
string abbrev;
|
||||
if (words.Count >= 3)
|
||||
{
|
||||
// Take first letter of first 3 words
|
||||
abbrev = string.Concat(words.Take(3).Select(w => w[0]));
|
||||
}
|
||||
else if (words.Count == 2)
|
||||
{
|
||||
// First letter of each word + second char of last word
|
||||
abbrev = $"{words[0][0]}{words[1][0]}{(words[1].Length > 1 ? words[1][1] : words[0][1])}";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single word — take up to 3 chars
|
||||
abbrev = words[0].Length >= 3 ? words[0][..3] : words[0].PadRight(3, 'X');
|
||||
}
|
||||
|
||||
abbrev = Regex.Replace(abbrev.ToUpperInvariant(), @"[^A-Z0-9]", "");
|
||||
if (abbrev.Length > 3) abbrev = abbrev[..3];
|
||||
|
||||
// Check uniqueness
|
||||
if (!await _db.Customers.AnyAsync(c => c.Abbreviation == abbrev))
|
||||
{
|
||||
_logger.LogInformation("Generated abbreviation {Abbrev} for '{CompanyName}'", abbrev, companyName);
|
||||
return abbrev;
|
||||
}
|
||||
|
||||
// Collision — try suffix 2–9
|
||||
var prefix = abbrev[..2];
|
||||
for (var suffix = 2; suffix <= 9; suffix++)
|
||||
{
|
||||
var candidate = $"{prefix}{suffix}";
|
||||
if (!await _db.Customers.AnyAsync(c => c.Abbreviation == candidate))
|
||||
{
|
||||
_logger.LogInformation("Generated abbreviation {Abbrev} (collision resolved) for '{CompanyName}'",
|
||||
candidate, companyName);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"All abbreviation variants for '{companyName}' are taken ({prefix}2–{prefix}9).");
|
||||
}
|
||||
}
|
||||
173
OTSSignsOrchestrator.Server/Services/EmailService.cs
Normal file
173
OTSSignsOrchestrator.Server/Services/EmailService.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user