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:
248
OTSSignsOrchestrator.Server/Api/CustomerPortalApi.cs
Normal file
248
OTSSignsOrchestrator.Server/Api/CustomerPortalApi.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Server.Clients;
|
||||
using OTSSignsOrchestrator.Server.Data;
|
||||
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||
using OTSSignsOrchestrator.Server.Hubs;
|
||||
using OTSSignsOrchestrator.Server.Workers;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Api;
|
||||
|
||||
public static class CustomerPortalApi
|
||||
{
|
||||
private const int MinCertDaysRemaining = 30;
|
||||
|
||||
public static void MapCustomerPortalEndpoints(this WebApplication app)
|
||||
{
|
||||
var portal = app.MapGroup("/api/portal/byoi")
|
||||
.RequireAuthorization("CustomerPortal");
|
||||
|
||||
portal.MapPost("/configure", HandleConfigureByoi);
|
||||
portal.MapGet("/sp-metadata", HandleGetSpMetadata);
|
||||
portal.MapPost("/rotate-cert", HandleRotateCert);
|
||||
}
|
||||
|
||||
// ── POST /api/portal/byoi/configure ─────────────────────────────────────
|
||||
private static async Task<IResult> HandleConfigureByoi(
|
||||
ConfigureByoiRequest req,
|
||||
OrchestratorDbContext db,
|
||||
IHubContext<FleetHub, IFleetClient> hub,
|
||||
HttpContext httpContext,
|
||||
ILogger<ConfigureByoiRequest> logger)
|
||||
{
|
||||
// Resolve customer from the authenticated JWT
|
||||
var customer = await ResolveCustomerAsync(httpContext, db);
|
||||
if (customer is null)
|
||||
return Results.Forbid();
|
||||
|
||||
if (customer.Plan != CustomerPlan.Pro)
|
||||
return Results.Problem("BYOI SAML is only available for Pro tier customers.", statusCode: 403);
|
||||
|
||||
// Validate cert PEM
|
||||
var certValidation = ValidateCertPem(req.CertPem);
|
||||
if (certValidation is not null)
|
||||
return Results.ValidationProblem(
|
||||
new Dictionary<string, string[]> { ["certPem"] = [certValidation] });
|
||||
|
||||
// Validate required fields
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
if (string.IsNullOrWhiteSpace(req.SsoUrl))
|
||||
errors["ssoUrl"] = ["ssoUrl is required."];
|
||||
if (string.IsNullOrWhiteSpace(req.IdpEntityId))
|
||||
errors["idpEntityId"] = ["idpEntityId is required."];
|
||||
if (errors.Count > 0)
|
||||
return Results.ValidationProblem(errors);
|
||||
|
||||
// Create a provision-byoi Job
|
||||
var parametersJson = JsonSerializer.Serialize(new ByoiParameters
|
||||
{
|
||||
CustomerCertPem = req.CertPem!,
|
||||
CustomerSsoUrl = req.SsoUrl!,
|
||||
CustomerIdpEntityId = req.IdpEntityId!,
|
||||
CustomerSloUrl = req.SloUrl,
|
||||
});
|
||||
|
||||
var job = new Job
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CustomerId = customer.Id,
|
||||
JobType = "provision-byoi",
|
||||
Status = JobStatus.Queued,
|
||||
TriggeredBy = $"customer-portal:{customer.AdminEmail}",
|
||||
Parameters = parametersJson,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
db.Jobs.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation("BYOI configure job {JobId} created for customer {CustomerId}",
|
||||
job.Id, customer.Id);
|
||||
|
||||
await hub.Clients.All.SendJobCreated(
|
||||
job.Id.ToString(), customer.Abbreviation, job.JobType);
|
||||
|
||||
return Results.Created($"/api/jobs/{job.Id}", new { jobId = job.Id });
|
||||
}
|
||||
|
||||
// ── GET /api/portal/byoi/sp-metadata ────────────────────────────────────
|
||||
private static async Task<IResult> HandleGetSpMetadata(
|
||||
OrchestratorDbContext db,
|
||||
IAuthentikClient authentikClient,
|
||||
HttpContext httpContext,
|
||||
ILogger<ConfigureByoiRequest> logger)
|
||||
{
|
||||
var customer = await ResolveCustomerAsync(httpContext, db);
|
||||
if (customer is null)
|
||||
return Results.Forbid();
|
||||
|
||||
var instance = customer.Instances.FirstOrDefault();
|
||||
if (instance is null)
|
||||
return Results.NotFound("No instance found for this customer.");
|
||||
|
||||
var byoiConfig = await db.ByoiConfigs
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(b => b.InstanceId == instance.Id && b.Enabled);
|
||||
|
||||
if (byoiConfig is null)
|
||||
return Results.NotFound("No BYOI configuration found for this instance.");
|
||||
|
||||
var metadataResponse = await authentikClient.GetSamlSourceMetadataAsync(byoiConfig.Slug);
|
||||
if (!metadataResponse.IsSuccessStatusCode || metadataResponse.Content is null)
|
||||
{
|
||||
logger.LogError("Failed to fetch SP metadata for slug {Slug}: {Error}",
|
||||
byoiConfig.Slug, metadataResponse.Error?.Content ?? metadataResponse.ReasonPhrase);
|
||||
return Results.Problem("Failed to retrieve SP metadata from Authentik.", statusCode: 502);
|
||||
}
|
||||
|
||||
return Results.Content(metadataResponse.Content, "application/xml");
|
||||
}
|
||||
|
||||
// ── POST /api/portal/byoi/rotate-cert ───────────────────────────────────
|
||||
private static async Task<IResult> HandleRotateCert(
|
||||
RotateCertRequest req,
|
||||
OrchestratorDbContext db,
|
||||
IHubContext<FleetHub, IFleetClient> hub,
|
||||
HttpContext httpContext,
|
||||
ILogger<RotateCertRequest> logger)
|
||||
{
|
||||
var customer = await ResolveCustomerAsync(httpContext, db);
|
||||
if (customer is null)
|
||||
return Results.Forbid();
|
||||
|
||||
if (customer.Plan != CustomerPlan.Pro)
|
||||
return Results.Problem("BYOI SAML is only available for Pro tier customers.", statusCode: 403);
|
||||
|
||||
// Validate cert PEM
|
||||
var certValidation = ValidateCertPem(req.CertPem);
|
||||
if (certValidation is not null)
|
||||
return Results.ValidationProblem(
|
||||
new Dictionary<string, string[]> { ["certPem"] = [certValidation] });
|
||||
|
||||
var instance = customer.Instances.FirstOrDefault();
|
||||
if (instance is null)
|
||||
return Results.NotFound("No instance found for this customer.");
|
||||
|
||||
var existingConfig = await db.ByoiConfigs
|
||||
.FirstOrDefaultAsync(b => b.InstanceId == instance.Id && b.Enabled);
|
||||
|
||||
if (existingConfig is null)
|
||||
return Results.NotFound("No active BYOI configuration found to rotate.");
|
||||
|
||||
// Create a re-provisioning job with the new cert
|
||||
var parametersJson = JsonSerializer.Serialize(new ByoiParameters
|
||||
{
|
||||
CustomerCertPem = req.CertPem!,
|
||||
CustomerSsoUrl = existingConfig.SsoUrl,
|
||||
CustomerIdpEntityId = existingConfig.EntityId,
|
||||
CustomerSloUrl = null,
|
||||
});
|
||||
|
||||
var job = new Job
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CustomerId = customer.Id,
|
||||
JobType = "provision-byoi",
|
||||
Status = JobStatus.Queued,
|
||||
TriggeredBy = $"customer-portal:cert-rotate:{customer.AdminEmail}",
|
||||
Parameters = parametersJson,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
db.Jobs.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation("BYOI cert rotate job {JobId} created for customer {CustomerId}",
|
||||
job.Id, customer.Id);
|
||||
|
||||
await hub.Clients.All.SendJobCreated(
|
||||
job.Id.ToString(), customer.Abbreviation, job.JobType);
|
||||
|
||||
return Results.Ok(new { jobId = job.Id });
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Validates a PEM certificate string. Returns an error message on failure, or null if valid.
|
||||
/// Rejects self-signed, expired, and certs expiring in < 30 days.
|
||||
/// </summary>
|
||||
private static string? ValidateCertPem(string? certPem)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(certPem))
|
||||
return "certPem is required.";
|
||||
|
||||
X509Certificate2 cert;
|
||||
try
|
||||
{
|
||||
var base64 = certPem
|
||||
.Replace("-----BEGIN CERTIFICATE-----", "")
|
||||
.Replace("-----END CERTIFICATE-----", "")
|
||||
.Replace("\r", "")
|
||||
.Replace("\n", "")
|
||||
.Trim();
|
||||
|
||||
cert = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(base64));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return "Invalid certificate PEM format.";
|
||||
}
|
||||
|
||||
using (cert)
|
||||
{
|
||||
if (cert.NotAfter.ToUniversalTime() < DateTime.UtcNow)
|
||||
return "Certificate has already expired.";
|
||||
|
||||
if ((cert.NotAfter.ToUniversalTime() - DateTime.UtcNow).TotalDays < MinCertDaysRemaining)
|
||||
return $"Certificate expires in less than {MinCertDaysRemaining} days. Provide a certificate with a longer validity period.";
|
||||
|
||||
// Reject self-signed: issuer == subject
|
||||
if (string.Equals(cert.Issuer, cert.Subject, StringComparison.OrdinalIgnoreCase))
|
||||
return "Self-signed certificates are not accepted. Provide a CA-signed certificate.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the current customer from the authenticated JWT claims.
|
||||
/// Expects a "customer_id" claim in the token.
|
||||
/// </summary>
|
||||
private static async Task<Customer?> ResolveCustomerAsync(HttpContext httpContext, OrchestratorDbContext db)
|
||||
{
|
||||
var customerIdClaim = httpContext.User.FindFirst("customer_id")?.Value;
|
||||
if (customerIdClaim is null || !Guid.TryParse(customerIdClaim, out var customerId))
|
||||
return null;
|
||||
|
||||
return await db.Customers
|
||||
.Include(c => c.Instances)
|
||||
.FirstOrDefaultAsync(c => c.Id == customerId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request DTOs ────────────────────────────────────────────────────────────
|
||||
public record ConfigureByoiRequest(string? CertPem, string? SsoUrl, string? IdpEntityId, string? SloUrl);
|
||||
public record RotateCertRequest(string? CertPem);
|
||||
245
OTSSignsOrchestrator.Server/Api/FleetApi.cs
Normal file
245
OTSSignsOrchestrator.Server/Api/FleetApi.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Server.Data;
|
||||
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||
using OTSSignsOrchestrator.Server.Hubs;
|
||||
using OTSSignsOrchestrator.Server.Reports;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Api;
|
||||
|
||||
public static class FleetApi
|
||||
{
|
||||
public static void MapFleetEndpoints(this WebApplication app)
|
||||
{
|
||||
var fleet = app.MapGroup("/api/fleet").RequireAuthorization();
|
||||
fleet.MapGet("/", GetFleetSummary);
|
||||
fleet.MapGet("/{id:guid}", GetFleetDetail);
|
||||
|
||||
var jobs = app.MapGroup("/api/jobs").RequireAuthorization();
|
||||
jobs.MapPost("/", CreateJob);
|
||||
jobs.MapGet("/{id:guid}", GetJob);
|
||||
|
||||
app.MapGet("/api/health", () => Results.Ok(new { status = "healthy" }));
|
||||
|
||||
// ── Report endpoints (admin only) ────────────────────────────────────
|
||||
var reports = app.MapGroup("/api/reports").RequireAuthorization()
|
||||
.RequireAuthorization(policy => policy.RequireRole("admin"));
|
||||
|
||||
reports.MapGet("/billing", GetBillingCsv);
|
||||
reports.MapGet("/version-drift", GetVersionDriftCsv);
|
||||
reports.MapGet("/fleet-health", GetFleetHealthPdf);
|
||||
reports.MapGet("/customer/{id:guid}/usage", GetCustomerUsagePdf);
|
||||
|
||||
fleet.MapPost("/bulk/export-fleet-report", ExportFleetReport)
|
||||
.RequireAuthorization(policy => policy.RequireRole("admin"));
|
||||
}
|
||||
|
||||
// ── GET /api/fleet ──────────────────────────────────────────────────────
|
||||
private static async Task<IResult> GetFleetSummary(OrchestratorDbContext db)
|
||||
{
|
||||
var customers = await db.Customers
|
||||
.AsNoTracking()
|
||||
.Include(c => c.Instances)
|
||||
.Include(c => c.Jobs)
|
||||
.ToListAsync();
|
||||
|
||||
// Get latest health event per instance in one query
|
||||
var latestHealth = await db.HealthEvents
|
||||
.AsNoTracking()
|
||||
.GroupBy(h => h.InstanceId)
|
||||
.Select(g => g.OrderByDescending(h => h.OccurredAt).First())
|
||||
.ToDictionaryAsync(h => h.InstanceId);
|
||||
|
||||
var result = customers.Select(c =>
|
||||
{
|
||||
var primaryInstance = c.Instances.FirstOrDefault();
|
||||
HealthEvent? health = null;
|
||||
if (primaryInstance is not null)
|
||||
latestHealth.TryGetValue(primaryInstance.Id, out health);
|
||||
|
||||
return new FleetSummaryDto
|
||||
{
|
||||
CustomerId = c.Id,
|
||||
Abbreviation = c.Abbreviation,
|
||||
CompanyName = c.CompanyName,
|
||||
Plan = c.Plan.ToString(),
|
||||
ScreenCount = c.ScreenCount,
|
||||
HealthStatus = health?.Status.ToString() ?? primaryInstance?.HealthStatus.ToString() ?? "Unknown",
|
||||
LastHealthCheck = health?.OccurredAt ?? primaryInstance?.LastHealthCheck,
|
||||
HasRunningJob = c.Jobs.Any(j => j.Status == JobStatus.Running || j.Status == JobStatus.Queued),
|
||||
};
|
||||
}).ToArray();
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
// ── GET /api/fleet/{id} ─────────────────────────────────────────────────
|
||||
private static async Task<IResult> GetFleetDetail(Guid id, OrchestratorDbContext db)
|
||||
{
|
||||
var customer = await db.Customers
|
||||
.AsNoTracking()
|
||||
.Include(c => c.Instances)
|
||||
.Include(c => c.Jobs.Where(j => j.Status == JobStatus.Running || j.Status == JobStatus.Queued))
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (customer is null)
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
customer.Id,
|
||||
customer.Abbreviation,
|
||||
customer.CompanyName,
|
||||
customer.AdminEmail,
|
||||
Plan = customer.Plan.ToString(),
|
||||
customer.ScreenCount,
|
||||
Status = customer.Status.ToString(),
|
||||
customer.CreatedAt,
|
||||
Instances = customer.Instances.Select(i => new
|
||||
{
|
||||
i.Id,
|
||||
i.XiboUrl,
|
||||
i.DockerStackName,
|
||||
HealthStatus = i.HealthStatus.ToString(),
|
||||
i.LastHealthCheck,
|
||||
}),
|
||||
ActiveJobs = customer.Jobs.Select(j => new
|
||||
{
|
||||
j.Id,
|
||||
j.JobType,
|
||||
Status = j.Status.ToString(),
|
||||
j.CreatedAt,
|
||||
j.StartedAt,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// ── POST /api/jobs ──────────────────────────────────────────────────────
|
||||
private static async Task<IResult> CreateJob(
|
||||
CreateJobRequest req,
|
||||
OrchestratorDbContext db,
|
||||
IHubContext<FleetHub, IFleetClient> hub,
|
||||
ILogger<CreateJobRequest> logger)
|
||||
{
|
||||
var customer = await db.Customers.FindAsync(req.CustomerId);
|
||||
if (customer is null)
|
||||
return Results.NotFound("Customer not found.");
|
||||
|
||||
var job = new Job
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CustomerId = req.CustomerId,
|
||||
JobType = req.JobType,
|
||||
Status = JobStatus.Queued,
|
||||
TriggeredBy = "operator",
|
||||
Parameters = req.Parameters,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
db.Jobs.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation("Job created: {JobId} type={JobType} customer={CustomerId}",
|
||||
job.Id, job.JobType, job.CustomerId);
|
||||
|
||||
await hub.Clients.All.SendJobCreated(job.Id.ToString(), customer.Abbreviation, job.JobType);
|
||||
|
||||
return Results.Created($"/api/jobs/{job.Id}", new { job.Id, job.JobType, Status = job.Status.ToString() });
|
||||
}
|
||||
|
||||
// ── GET /api/jobs/{id} ──────────────────────────────────────────────────
|
||||
private static async Task<IResult> GetJob(Guid id, OrchestratorDbContext db)
|
||||
{
|
||||
var job = await db.Jobs
|
||||
.AsNoTracking()
|
||||
.Include(j => j.Steps.OrderBy(s => s.StartedAt))
|
||||
.FirstOrDefaultAsync(j => j.Id == id);
|
||||
|
||||
if (job is null)
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
job.Id,
|
||||
job.CustomerId,
|
||||
job.JobType,
|
||||
Status = job.Status.ToString(),
|
||||
job.TriggeredBy,
|
||||
job.Parameters,
|
||||
job.CreatedAt,
|
||||
job.StartedAt,
|
||||
job.CompletedAt,
|
||||
job.ErrorMessage,
|
||||
Steps = job.Steps.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.StepName,
|
||||
Status = s.Status.ToString(),
|
||||
s.LogOutput,
|
||||
s.StartedAt,
|
||||
s.CompletedAt,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// ── GET /api/reports/billing?from=&to= ──────────────────────────────────
|
||||
private static async Task<IResult> GetBillingCsv(
|
||||
DateOnly from, DateOnly to, BillingReportService billing)
|
||||
{
|
||||
var csv = await billing.GenerateBillingCsvAsync(from, to);
|
||||
return Results.File(csv, "text/csv", $"billing-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.csv");
|
||||
}
|
||||
|
||||
// ── GET /api/reports/version-drift ──────────────────────────────────────
|
||||
private static async Task<IResult> GetVersionDriftCsv(BillingReportService billing)
|
||||
{
|
||||
var csv = await billing.GenerateVersionDriftCsvAsync();
|
||||
return Results.File(csv, "text/csv", $"version-drift-{DateTime.UtcNow:yyyy-MM-dd}.csv");
|
||||
}
|
||||
|
||||
// ── GET /api/reports/fleet-health?from=&to= ─────────────────────────────
|
||||
private static async Task<IResult> GetFleetHealthPdf(
|
||||
DateOnly from, DateOnly to, FleetHealthPdfService pdfService)
|
||||
{
|
||||
var pdf = await pdfService.GenerateFleetHealthPdfAsync(from, to);
|
||||
return Results.File(pdf, "application/pdf", $"fleet-health-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
|
||||
}
|
||||
|
||||
// ── GET /api/reports/customer/{id}/usage?from=&to= ──────────────────────
|
||||
private static async Task<IResult> GetCustomerUsagePdf(
|
||||
Guid id, DateOnly from, DateOnly to, FleetHealthPdfService pdfService)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pdf = await pdfService.GenerateCustomerUsagePdfAsync(id, from, to);
|
||||
return Results.File(pdf, "application/pdf", $"customer-usage-{id}-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/fleet/bulk/export-fleet-report ────────────────────────────
|
||||
private static async Task<IResult> ExportFleetReport(FleetHealthPdfService pdfService)
|
||||
{
|
||||
var to = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = to.AddDays(-7);
|
||||
var pdf = await pdfService.GenerateFleetHealthPdfAsync(from, to);
|
||||
return Results.File(pdf, "application/pdf", $"fleet-health-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
public record FleetSummaryDto
|
||||
{
|
||||
public Guid CustomerId { get; init; }
|
||||
public string Abbreviation { get; init; } = string.Empty;
|
||||
public string CompanyName { get; init; } = string.Empty;
|
||||
public string Plan { get; init; } = string.Empty;
|
||||
public int ScreenCount { get; init; }
|
||||
public string HealthStatus { get; init; } = "Unknown";
|
||||
public DateTime? LastHealthCheck { get; init; }
|
||||
public bool HasRunningJob { get; init; }
|
||||
}
|
||||
|
||||
public record CreateJobRequest(Guid CustomerId, string JobType, string? Parameters);
|
||||
173
OTSSignsOrchestrator.Server/Api/SignupApi.cs
Normal file
173
OTSSignsOrchestrator.Server/Api/SignupApi.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Server.Data;
|
||||
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||
using OTSSignsOrchestrator.Server.Hubs;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Api;
|
||||
|
||||
public static class SignupApi
|
||||
{
|
||||
public static void MapSignupEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapPost("/api/signup/initiate", HandleInitiate)
|
||||
.RequireRateLimiting("signup");
|
||||
|
||||
app.MapGet("/api/signup/status/{token:guid}", HandleStatus);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleInitiate(
|
||||
SignupRequest req,
|
||||
OrchestratorDbContext db,
|
||||
IConfiguration config,
|
||||
ILogger<SignupRequest> logger)
|
||||
{
|
||||
// ── Validation ──────────────────────────────────────────────────────
|
||||
var errors = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(req.CompanyName))
|
||||
errors.Add("companyName is required.");
|
||||
if (string.IsNullOrWhiteSpace(req.AdminEmail) || !new EmailAddressAttribute().IsValid(req.AdminEmail))
|
||||
errors.Add("A valid adminEmail is required.");
|
||||
if (string.IsNullOrWhiteSpace(req.Plan) ||
|
||||
!req.Plan.Equals("Essentials", StringComparison.OrdinalIgnoreCase) &&
|
||||
!req.Plan.Equals("Pro", StringComparison.OrdinalIgnoreCase))
|
||||
errors.Add("plan must be 'Essentials' or 'Pro'.");
|
||||
if (req.ScreenCount < 1)
|
||||
errors.Add("screenCount must be at least 1.");
|
||||
if (req.Plan?.Equals("Essentials", StringComparison.OrdinalIgnoreCase) == true && req.ScreenCount > 50)
|
||||
errors.Add("Essentials plan supports a maximum of 50 screens.");
|
||||
if (string.IsNullOrWhiteSpace(req.BillingFrequency) ||
|
||||
!req.BillingFrequency.Equals("monthly", StringComparison.OrdinalIgnoreCase) &&
|
||||
!req.BillingFrequency.Equals("annual", StringComparison.OrdinalIgnoreCase))
|
||||
errors.Add("billingFrequency must be 'monthly' or 'annual'.");
|
||||
|
||||
if (errors.Count > 0)
|
||||
return Results.ValidationProblem(
|
||||
errors.ToDictionary(e => e, _ => new[] { "Validation failed." }));
|
||||
|
||||
// ── Create pending customer ─────────────────────────────────────────
|
||||
var plan = Enum.Parse<CustomerPlan>(req.Plan!, true);
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CompanyName = req.CompanyName!.Trim(),
|
||||
AdminEmail = req.AdminEmail!.Trim().ToLowerInvariant(),
|
||||
AdminFirstName = req.AdminFirstName?.Trim() ?? string.Empty,
|
||||
AdminLastName = req.AdminLastName?.Trim() ?? string.Empty,
|
||||
Plan = plan,
|
||||
ScreenCount = req.ScreenCount,
|
||||
Status = CustomerStatus.PendingPayment,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
db.Customers.Add(customer);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// ── Stripe Checkout Session ─────────────────────────────────────────
|
||||
var priceKey = $"Stripe:Prices:{req.Plan}:{req.BillingFrequency}".ToLowerInvariant();
|
||||
var priceId = config[priceKey];
|
||||
if (string.IsNullOrWhiteSpace(priceId))
|
||||
{
|
||||
logger.LogError("Stripe price ID not configured for key {PriceKey}", priceKey);
|
||||
return Results.Problem("Billing configuration error. Contact support.", statusCode: 500);
|
||||
}
|
||||
|
||||
var sessionOptions = new SessionCreateOptions
|
||||
{
|
||||
Mode = "subscription",
|
||||
CustomerEmail = customer.AdminEmail,
|
||||
LineItems = new List<SessionLineItemOptions>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Price = priceId,
|
||||
Quantity = req.ScreenCount,
|
||||
},
|
||||
},
|
||||
SubscriptionData = new SessionSubscriptionDataOptions
|
||||
{
|
||||
TrialPeriodDays = 14,
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["ots_customer_id"] = customer.Id.ToString(),
|
||||
["company_name"] = customer.CompanyName,
|
||||
["admin_email"] = customer.AdminEmail,
|
||||
["admin_first_name"] = customer.AdminFirstName,
|
||||
["admin_last_name"] = customer.AdminLastName,
|
||||
["plan"] = req.Plan!,
|
||||
["screen_count"] = req.ScreenCount.ToString(),
|
||||
["billing_frequency"] = req.BillingFrequency!,
|
||||
},
|
||||
SuccessUrl = config["Stripe:SuccessUrl"] ?? "https://app.ots-signs.com/signup/success?session_id={CHECKOUT_SESSION_ID}",
|
||||
CancelUrl = config["Stripe:CancelUrl"] ?? "https://app.ots-signs.com/signup/cancel",
|
||||
};
|
||||
|
||||
var sessionService = new SessionService();
|
||||
var session = await sessionService.CreateAsync(sessionOptions);
|
||||
|
||||
customer.StripeCheckoutSessionId = session.Id;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation(
|
||||
"Signup initiated: customer={CustomerId}, company={Company}, plan={Plan}, screens={Screens}",
|
||||
customer.Id, customer.CompanyName, req.Plan, req.ScreenCount);
|
||||
|
||||
return Results.Ok(new { checkoutUrl = session.Url, statusToken = customer.Id });
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleStatus(
|
||||
Guid token,
|
||||
OrchestratorDbContext db)
|
||||
{
|
||||
var customer = await db.Customers
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == token);
|
||||
|
||||
if (customer is null)
|
||||
return Results.NotFound();
|
||||
|
||||
// Find latest provisioning job if any
|
||||
var job = await db.Jobs
|
||||
.AsNoTracking()
|
||||
.Where(j => j.CustomerId == customer.Id && j.JobType == "provision")
|
||||
.OrderByDescending(j => j.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
int pctComplete = customer.Status switch
|
||||
{
|
||||
CustomerStatus.PendingPayment => 0,
|
||||
CustomerStatus.Provisioning => job?.Status switch
|
||||
{
|
||||
JobStatus.Running => 50,
|
||||
JobStatus.Completed => 100,
|
||||
_ => 10,
|
||||
},
|
||||
CustomerStatus.Active => 100,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
status = customer.Status.ToString(),
|
||||
provisioningStep = job?.Steps
|
||||
.Where(s => s.Status == JobStepStatus.Running)
|
||||
.Select(s => s.StepName)
|
||||
.FirstOrDefault() ?? (customer.Status == CustomerStatus.Active ? "complete" : "waiting"),
|
||||
pctComplete,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public record SignupRequest(
|
||||
string? CompanyName,
|
||||
string? AdminFirstName,
|
||||
string? AdminLastName,
|
||||
string? AdminEmail,
|
||||
string? Phone,
|
||||
string? Plan,
|
||||
int ScreenCount,
|
||||
string? BillingFrequency,
|
||||
string? PromoCode);
|
||||
Reference in New Issue
Block a user