feat: Add customer type and update related logic for provisioning and customer management
This commit is contained in:
@@ -32,8 +32,9 @@ public static class CustomersApi
|
||||
c.AdminEmail,
|
||||
c.AdminFirstName,
|
||||
c.AdminLastName,
|
||||
Plan = c.Plan.ToString(),
|
||||
Plan = c.Plan.HasValue ? c.Plan.Value.ToString() : null,
|
||||
c.ScreenCount,
|
||||
CustomerType = c.CustomerType.ToString(),
|
||||
c.StripeCustomerId,
|
||||
c.StripeSubscriptionId,
|
||||
Status = c.Status.ToString(),
|
||||
@@ -88,8 +89,9 @@ public static class CustomersApi
|
||||
customer.AdminEmail,
|
||||
customer.AdminFirstName,
|
||||
customer.AdminLastName,
|
||||
Plan = customer.Plan.ToString(),
|
||||
Plan = customer.Plan.HasValue ? customer.Plan.Value.ToString() : null,
|
||||
customer.ScreenCount,
|
||||
CustomerType = customer.CustomerType.ToString(),
|
||||
customer.StripeCustomerId,
|
||||
customer.StripeSubscriptionId,
|
||||
Status = customer.Status.ToString(),
|
||||
|
||||
@@ -137,8 +137,24 @@ public static class ProvisionApi
|
||||
if (await db.Customers.AnyAsync(c => c.Abbreviation == abbrev))
|
||||
return Results.Conflict(new { message = $"Abbreviation '{abbrev}' is already in use." });
|
||||
|
||||
if (!Enum.TryParse<CustomerPlan>(req.Plan, true, out var plan))
|
||||
return Results.BadRequest("Invalid plan. Must be Essentials or Pro.");
|
||||
if (!Enum.TryParse<CustomerType>(req.CustomerType ?? "Standard", true, out var customerType))
|
||||
return Results.BadRequest("Invalid customer type. Must be Standard, Internal, Demo, or Trial.");
|
||||
|
||||
CustomerPlan? plan = null;
|
||||
if (customerType == CustomerType.Standard)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Plan))
|
||||
return Results.BadRequest("Plan is required for Standard instances.");
|
||||
if (!Enum.TryParse<CustomerPlan>(req.Plan, true, out var parsedPlan))
|
||||
return Results.BadRequest("Invalid plan. Must be Essentials or Pro.");
|
||||
plan = parsedPlan;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(req.Plan))
|
||||
{
|
||||
if (!Enum.TryParse<CustomerPlan>(req.Plan, true, out var parsedPlan))
|
||||
return Results.BadRequest("Invalid plan. Must be Essentials or Pro.");
|
||||
plan = parsedPlan;
|
||||
}
|
||||
|
||||
// Create Customer record
|
||||
var customer = new Customer
|
||||
@@ -150,7 +166,10 @@ public static class ProvisionApi
|
||||
AdminLastName = req.AdminLastName?.Trim() ?? string.Empty,
|
||||
Abbreviation = abbrev,
|
||||
Plan = plan,
|
||||
ScreenCount = Math.Max(1, req.ScreenCount),
|
||||
ScreenCount = customerType == CustomerType.Standard
|
||||
? Math.Max(1, req.ScreenCount ?? 1)
|
||||
: req.ScreenCount ?? 0,
|
||||
CustomerType = customerType,
|
||||
Status = CustomerStatus.Provisioning,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
@@ -233,8 +252,9 @@ public record ManualProvisionRequest(
|
||||
string? AdminFirstName,
|
||||
string? AdminLastName,
|
||||
string? Abbreviation,
|
||||
string Plan,
|
||||
int ScreenCount,
|
||||
string? CustomerType,
|
||||
string? Plan,
|
||||
int? ScreenCount,
|
||||
string? NewtId,
|
||||
string? NewtSecret,
|
||||
string? NfsServer,
|
||||
|
||||
@@ -7,8 +7,9 @@ export interface CustomerListItem {
|
||||
adminEmail: string;
|
||||
adminFirstName: string;
|
||||
adminLastName: string;
|
||||
plan: string;
|
||||
plan: string | null;
|
||||
screenCount: number;
|
||||
customerType: string;
|
||||
stripeCustomerId: string | null;
|
||||
stripeSubscriptionId: string | null;
|
||||
status: string;
|
||||
|
||||
@@ -19,8 +19,9 @@ export interface ManualProvisionRequest {
|
||||
adminFirstName?: string;
|
||||
adminLastName?: string;
|
||||
abbreviation?: string;
|
||||
plan: string;
|
||||
screenCount: number;
|
||||
customerType?: string;
|
||||
plan?: string;
|
||||
screenCount?: number;
|
||||
newtId?: string;
|
||||
newtSecret?: string;
|
||||
nfsServer?: string;
|
||||
|
||||
@@ -17,6 +17,7 @@ interface FormState {
|
||||
adminFirstName: string;
|
||||
adminLastName: string;
|
||||
abbreviation: string;
|
||||
customerType: string;
|
||||
plan: string;
|
||||
screenCount: number;
|
||||
newtId: string;
|
||||
@@ -33,6 +34,7 @@ const initialForm: FormState = {
|
||||
adminFirstName: '',
|
||||
adminLastName: '',
|
||||
abbreviation: '',
|
||||
customerType: 'Standard',
|
||||
plan: 'Essentials',
|
||||
screenCount: 1,
|
||||
newtId: '',
|
||||
@@ -62,14 +64,16 @@ export default function CreateInstancePage() {
|
||||
|
||||
const deployMut = useMutation({
|
||||
mutationFn: () => {
|
||||
const isStandard = form.customerType === 'Standard';
|
||||
const req: ManualProvisionRequest = {
|
||||
companyName: form.companyName,
|
||||
adminEmail: form.adminEmail,
|
||||
adminFirstName: form.adminFirstName || undefined,
|
||||
adminLastName: form.adminLastName || undefined,
|
||||
abbreviation: form.abbreviation || undefined,
|
||||
plan: form.plan,
|
||||
screenCount: form.screenCount,
|
||||
customerType: form.customerType,
|
||||
plan: isStandard || form.plan ? form.plan : undefined,
|
||||
screenCount: isStandard ? form.screenCount : (form.screenCount > 0 ? form.screenCount : undefined),
|
||||
newtId: form.newtId || undefined,
|
||||
newtSecret: form.newtSecret || undefined,
|
||||
nfsServer: form.nfsServer || undefined,
|
||||
@@ -99,6 +103,7 @@ export default function CreateInstancePage() {
|
||||
if (!form.adminEmail.trim()) e.adminEmail = 'Admin email is required';
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.adminEmail)) e.adminEmail = 'Invalid email address';
|
||||
if (form.abbreviation && !/^[a-z]{3}$/.test(form.abbreviation)) e.abbreviation = 'Must be exactly 3 lowercase letters';
|
||||
if (form.customerType === 'Standard' && form.screenCount < 1) e.screenCount = 'Screen count must be at least 1';
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
};
|
||||
@@ -188,10 +193,26 @@ export default function CreateInstancePage() {
|
||||
|
||||
{/* Plan & Abbreviation */}
|
||||
<section>
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-400">Plan & Identity</h3>
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-400">Instance Type & Plan</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="cip-type" className="mb-1 block text-xs text-gray-400">Instance Type</label>
|
||||
<select
|
||||
id="cip-type"
|
||||
value={form.customerType}
|
||||
onChange={(e) => set('customerType', e.target.value)}
|
||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
|
||||
>
|
||||
<option value="Standard">Standard (billed customer)</option>
|
||||
<option value="Internal">Internal (OTS staff)</option>
|
||||
<option value="Demo">Demo (prospect showcase)</option>
|
||||
<option value="Trial">Trial (evaluation, no payment)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="cip-plan" className="mb-1 block text-xs text-gray-400">Plan</label>
|
||||
<label htmlFor="cip-plan" className="mb-1 block text-xs text-gray-400">
|
||||
Plan{form.customerType !== 'Standard' && <span className="ml-1 text-gray-500">(optional)</span>}
|
||||
</label>
|
||||
<select
|
||||
id="cip-plan"
|
||||
value={form.plan}
|
||||
@@ -203,7 +224,9 @@ export default function CreateInstancePage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="cip-screens" className="mb-1 block text-xs text-gray-400">Screen Count</label>
|
||||
<label htmlFor="cip-screens" className="mb-1 block text-xs text-gray-400">
|
||||
Screen Count{form.customerType !== 'Standard' && <span className="ml-1 text-gray-500">(optional)</span>}
|
||||
</label>
|
||||
<input
|
||||
id="cip-screens"
|
||||
type="number"
|
||||
|
||||
@@ -31,6 +31,12 @@ const statusText: Record<string, string> = {
|
||||
Decommissioned: 'text-status-danger',
|
||||
};
|
||||
|
||||
const typeBadgeVariant: Record<string, string> = {
|
||||
Internal: 'bg-blue-900/60 text-blue-300 border-blue-700',
|
||||
Demo: 'bg-purple-900/60 text-purple-300 border-purple-700',
|
||||
Trial: 'bg-orange-900/60 text-orange-300 border-orange-700',
|
||||
};
|
||||
|
||||
const statuses = ['Active', 'Provisioning', 'PendingPayment', 'Suspended', 'Decommissioned'] as const;
|
||||
|
||||
export default function CustomersPage() {
|
||||
@@ -132,6 +138,11 @@ export default function CustomersPage() {
|
||||
<TableCell>
|
||||
<div>
|
||||
<span className="font-medium">{c.companyName}</span>
|
||||
{c.customerType && c.customerType !== 'Standard' && typeBadgeVariant[c.customerType] && (
|
||||
<span className={`ml-2 inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${typeBadgeVariant[c.customerType]}`}>
|
||||
{c.customerType}
|
||||
</span>
|
||||
)}
|
||||
{c.failedPaymentCount > 0 && (
|
||||
<Badge variant="destructive" className="ml-2">{c.failedPaymentCount} failed</Badge>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,14 @@ public enum CustomerStatus
|
||||
Decommissioned
|
||||
}
|
||||
|
||||
public enum CustomerType
|
||||
{
|
||||
Standard,
|
||||
Internal,
|
||||
Demo,
|
||||
Trial
|
||||
}
|
||||
|
||||
public class Customer
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
@@ -23,8 +31,9 @@ public class Customer
|
||||
public string AdminEmail { get; set; } = string.Empty;
|
||||
public string AdminFirstName { get; set; } = string.Empty;
|
||||
public string AdminLastName { get; set; } = string.Empty;
|
||||
public CustomerPlan Plan { get; set; }
|
||||
public CustomerPlan? Plan { get; set; }
|
||||
public int ScreenCount { get; set; }
|
||||
public CustomerType CustomerType { get; set; }
|
||||
public string? StripeCustomerId { get; set; }
|
||||
public string? StripeSubscriptionId { get; set; }
|
||||
public string? StripeCheckoutSessionId { get; set; }
|
||||
|
||||
811
OTSSignsOrchestrator/Migrations/20260326233733_AddCustomerType.Designer.cs
generated
Normal file
811
OTSSignsOrchestrator/Migrations/20260326233733_AddCustomerType.Designer.cs
generated
Normal file
@@ -0,0 +1,811 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Migrations
|
||||
{
|
||||
[DbContext(typeof(OrchestratorDbContext))]
|
||||
[Migration("20260326233733_AddCustomerType")]
|
||||
partial class AddCustomerType
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.2")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("category");
|
||||
|
||||
b.Property<bool>("IsSensitive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_sensitive");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.HasIndex("Category");
|
||||
|
||||
b.ToTable("app_settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AuditLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("action");
|
||||
|
||||
b.Property<string>("Actor")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("actor");
|
||||
|
||||
b.Property<string>("Detail")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("detail");
|
||||
|
||||
b.Property<Guid?>("InstanceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<DateTime>("OccurredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("occurred_at");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("outcome");
|
||||
|
||||
b.Property<string>("Target")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("target");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_audit_logs");
|
||||
|
||||
b.HasIndex("InstanceId");
|
||||
|
||||
b.HasIndex("OccurredAt");
|
||||
|
||||
b.ToTable("audit_logs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AuthentikMetrics", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CheckedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("checked_at");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("error_message");
|
||||
|
||||
b.Property<int>("LatencyMs")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("latency_ms");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_authentik_metrics");
|
||||
|
||||
b.ToTable("authentik_metrics");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ByoiConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CertExpiry")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("cert_expiry");
|
||||
|
||||
b.Property<string>("CertPem")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("cert_pem");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<string>("EntityId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<Guid>("InstanceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<string>("SsoUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("sso_url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_byoi_configs");
|
||||
|
||||
b.HasIndex("InstanceId")
|
||||
.HasDatabaseName("ix_byoi_configs_instance_id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("byoi_configs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Customer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Abbreviation")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasColumnName("abbreviation");
|
||||
|
||||
b.Property<string>("AdminEmail")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("admin_email");
|
||||
|
||||
b.Property<string>("AdminFirstName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("admin_first_name");
|
||||
|
||||
b.Property<string>("AdminLastName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("admin_last_name");
|
||||
|
||||
b.Property<string>("CompanyName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("company_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("CustomerType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("customer_type");
|
||||
|
||||
b.Property<int>("FailedPaymentCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("failed_payment_count");
|
||||
|
||||
b.Property<DateTime?>("FirstPaymentFailedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("first_payment_failed_at");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("plan");
|
||||
|
||||
b.Property<int>("ScreenCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("screen_count");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("StripeCheckoutSessionId")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("stripe_checkout_session_id");
|
||||
|
||||
b.Property<string>("StripeCustomerId")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("stripe_customer_id");
|
||||
|
||||
b.Property<string>("StripeSubscriptionId")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("stripe_subscription_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_customers");
|
||||
|
||||
b.HasIndex("Abbreviation")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("StripeCustomerId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("customers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.HealthEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CheckName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("check_name");
|
||||
|
||||
b.Property<Guid>("InstanceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("message");
|
||||
|
||||
b.Property<DateTime>("OccurredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("occurred_at");
|
||||
|
||||
b.Property<bool>("Remediated")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("remediated");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_health_events");
|
||||
|
||||
b.HasIndex("InstanceId")
|
||||
.HasDatabaseName("ix_health_events_instance_id");
|
||||
|
||||
b.ToTable("health_events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AuthentikProviderId")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("authentik_provider_id");
|
||||
|
||||
b.Property<string>("CmsAdminPassRef")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("cms_admin_pass_ref");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("CustomerId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("customer_id");
|
||||
|
||||
b.Property<string>("DockerStackName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("docker_stack_name");
|
||||
|
||||
b.Property<string>("HealthStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("health_status");
|
||||
|
||||
b.Property<DateTime?>("LastHealthCheck")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_health_check");
|
||||
|
||||
b.Property<string>("MysqlDatabase")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("mysql_database");
|
||||
|
||||
b.Property<string>("NfsPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("nfs_path");
|
||||
|
||||
b.Property<string>("XiboUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("xibo_url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_instances");
|
||||
|
||||
b.HasIndex("CustomerId")
|
||||
.HasDatabaseName("ix_instances_customer_id");
|
||||
|
||||
b.HasIndex("DockerStackName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("instances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("CustomerId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("customer_id");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("error_message");
|
||||
|
||||
b.Property<string>("JobType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("job_type");
|
||||
|
||||
b.Property<string>("Parameters")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("parameters");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("TriggeredBy")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("triggered_by");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_jobs");
|
||||
|
||||
b.HasIndex("CustomerId")
|
||||
.HasDatabaseName("ix_jobs_customer_id");
|
||||
|
||||
b.ToTable("jobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.JobStep", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
b.Property<Guid>("JobId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("job_id");
|
||||
|
||||
b.Property<string>("LogOutput")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("log_output");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("StepName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("step_name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_job_steps");
|
||||
|
||||
b.HasIndex("JobId")
|
||||
.HasDatabaseName("ix_job_steps_job_id");
|
||||
|
||||
b.ToTable("job_steps");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OauthAppRegistry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ClientId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("client_id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("InstanceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_oauth_app_registries");
|
||||
|
||||
b.HasIndex("ClientId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("InstanceId")
|
||||
.HasDatabaseName("ix_oauth_app_registries_instance_id");
|
||||
|
||||
b.ToTable("oauth_app_registries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OperationLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long?>("DurationMs")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("duration_ms");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)")
|
||||
.HasColumnName("message");
|
||||
|
||||
b.Property<string>("Operation")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<string>("StackName")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)")
|
||||
.HasColumnName("stack_name");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("timestamp");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_operation_logs");
|
||||
|
||||
b.HasIndex("Operation");
|
||||
|
||||
b.HasIndex("StackName");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.ToTable("operation_logs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("InstanceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<int>("ScreenCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("screen_count");
|
||||
|
||||
b.Property<DateOnly>("SnapshotDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("snapshot_date");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_screen_snapshots");
|
||||
|
||||
b.HasIndex("InstanceId")
|
||||
.HasDatabaseName("ix_screen_snapshots_instance_id");
|
||||
|
||||
b.ToTable("screen_snapshots");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.SshHost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("host");
|
||||
|
||||
b.Property<bool>("IsLocal")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_local");
|
||||
|
||||
b.Property<string>("KeyPassphrase")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)")
|
||||
.HasColumnName("key_passphrase");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("label");
|
||||
|
||||
b.Property<bool?>("LastTestSuccess")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("last_test_success");
|
||||
|
||||
b.Property<DateTime?>("LastTestedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_tested_at");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)")
|
||||
.HasColumnName("password");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("port");
|
||||
|
||||
b.Property<string>("PrivateKeyPath")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("private_key_path");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<bool>("UseKeyAuth")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("use_key_auth");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_ssh_hosts");
|
||||
|
||||
b.HasIndex("Label")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ssh_hosts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.StripeEvent", b =>
|
||||
{
|
||||
b.Property<string>("StripeEventId")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("stripe_event_id");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("event_type");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("processed_at");
|
||||
|
||||
b.HasKey("StripeEventId")
|
||||
.HasName("pk_stripe_events");
|
||||
|
||||
b.ToTable("stripe_events");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ByoiConfig", b =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
|
||||
.WithMany("ByoiConfigs")
|
||||
.HasForeignKey("InstanceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_byoi_configs__instances_instance_id");
|
||||
|
||||
b.Navigation("Instance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.HealthEvent", b =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
|
||||
.WithMany("HealthEvents")
|
||||
.HasForeignKey("InstanceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_health_events__instances_instance_id");
|
||||
|
||||
b.Navigation("Instance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Data.Entities.Customer", "Customer")
|
||||
.WithMany("Instances")
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_instances_customers_customer_id");
|
||||
|
||||
b.Navigation("Customer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Data.Entities.Customer", "Customer")
|
||||
.WithMany("Jobs")
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_jobs_customers_customer_id");
|
||||
|
||||
b.Navigation("Customer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.JobStep", b =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Data.Entities.Job", "Job")
|
||||
.WithMany("Steps")
|
||||
.HasForeignKey("JobId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_job_steps_jobs_job_id");
|
||||
|
||||
b.Navigation("Job");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OauthAppRegistry", b =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
|
||||
.WithMany("OauthAppRegistries")
|
||||
.HasForeignKey("InstanceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_oauth_app_registries_instances_instance_id");
|
||||
|
||||
b.Navigation("Instance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
|
||||
.WithMany("ScreenSnapshots")
|
||||
.HasForeignKey("InstanceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_screen_snapshots_instances_instance_id");
|
||||
|
||||
b.Navigation("Instance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Customer", b =>
|
||||
{
|
||||
b.Navigation("Instances");
|
||||
|
||||
b.Navigation("Jobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
|
||||
{
|
||||
b.Navigation("ByoiConfigs");
|
||||
|
||||
b.Navigation("HealthEvents");
|
||||
|
||||
b.Navigation("OauthAppRegistries");
|
||||
|
||||
b.Navigation("ScreenSnapshots");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
|
||||
{
|
||||
b.Navigation("Steps");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomerType : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "plan",
|
||||
table: "customers",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "customer_type",
|
||||
table: "customers",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "customer_type",
|
||||
table: "customers");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "plan",
|
||||
table: "customers",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,6 +225,10 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("CustomerType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("customer_type");
|
||||
|
||||
b.Property<int>("FailedPaymentCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
@@ -236,7 +240,6 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
.HasColumnName("first_payment_failed_at");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("plan");
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ public class BillingReportService
|
||||
CompanyName = g.First().CompanyName,
|
||||
Date = g.Key.SnapshotDate,
|
||||
ScreenCount = g.Sum(r => r.ScreenCount),
|
||||
Plan = g.First().Plan.ToString(),
|
||||
Plan = g.First().Plan.HasValue ? g.First().Plan!.Value.ToString() : string.Empty,
|
||||
})
|
||||
.OrderBy(r => r.CustomerAbbrev)
|
||||
.ThenBy(r => r.Date)
|
||||
|
||||
@@ -186,6 +186,14 @@ public static class StripeWebhookHandler
|
||||
return;
|
||||
}
|
||||
|
||||
if (customer.CustomerType != CustomerType.Standard)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Skipping payment-failed billing logic for non-Standard customer {CustomerId} (type={Type})",
|
||||
customer.Id, customer.CustomerType);
|
||||
return;
|
||||
}
|
||||
|
||||
customer.FailedPaymentCount++;
|
||||
customer.FirstPaymentFailedAt ??= DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
@@ -239,6 +247,14 @@ public static class StripeWebhookHandler
|
||||
return;
|
||||
}
|
||||
|
||||
if (customer.CustomerType != CustomerType.Standard)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Skipping subscription-updated billing logic for non-Standard customer {CustomerId} (type={Type})",
|
||||
customer.Id, customer.CustomerType);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync screen count from subscription quantity
|
||||
var quantity = subscription.Items?.Data?.FirstOrDefault()?.Quantity;
|
||||
if (quantity.HasValue && (int)quantity.Value != customer.ScreenCount)
|
||||
@@ -311,6 +327,14 @@ public static class StripeWebhookHandler
|
||||
return;
|
||||
}
|
||||
|
||||
if (customer.CustomerType != CustomerType.Standard)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Skipping subscription-deleted billing logic for non-Standard customer {CustomerId} (type={Type})",
|
||||
customer.Id, customer.CustomerType);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Subscription deleted for customer {CustomerId} ({Company}) — creating decommission job",
|
||||
customer.Id, customer.CompanyName);
|
||||
|
||||
Reference in New Issue
Block a user