feat: complete SaaS commercial delivery workflows

This commit is contained in:
2026-08-01 15:26:21 +08:00
parent 46abf4d62f
commit d58bcd97e9
56 changed files with 27505 additions and 181 deletions

View File

@@ -1,7 +1,10 @@
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Tiku.Application.PlatformAdmin;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
@@ -128,12 +131,15 @@ internal sealed class PlatformAdminService(
.ToArrayAsync(cancellationToken);
var billingProfile = await dbContext.TenantBillingProfiles.AsNoTracking()
.SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken);
var billingPolicy = await dbContext.TenantBillingPolicies.AsNoTracking()
.SingleOrDefaultAsync(policy => policy.TenantId == tenantId, cancellationToken);
return new PlatformTenantDetail(
ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.CurrentPeriodEnd),
domains.Select(ToDomainItem).ToArray(),
subscriptions.Select(ToSubscriptionItem).ToArray(),
billingProfile is null ? null : ToBillingProfileItem(billingProfile));
billingProfile is null ? null : ToBillingProfileItem(billingProfile),
billingPolicy is null ? null : ToBillingPolicyItem(billingPolicy));
}, cancellationToken);
}
@@ -143,90 +149,225 @@ internal sealed class PlatformAdminService(
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant create", async (provider, dbContext) =>
var idempotencyKey = Required(command.IdempotencyKey, "Idempotency-Key");
var requestHash = ProvisioningRequestHash(command);
try
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
var slug = NormalizeCode(command.Slug);
if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken))
return await ExecuteSystemAsync("platform tenant create", async (provider, dbContext) =>
{
throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists");
}
var ownerEmail = Normalize(command.OwnerEmail);
var ownerPhone = Normalize(command.OwnerPhone);
var ownerIdentifier = ownerEmail ?? ownerPhone;
if (ownerIdentifier is null)
{
throw new PlatformAdminException("Owner email or phone is required.", "tenant_owner_identifier_required");
}
if (await dbContext.Users.AnyAsync(user =>
(ownerEmail != null && user.NormalizedEmail == ownerEmail.ToUpperInvariant()) ||
(ownerPhone != null && user.Phone == ownerPhone), cancellationToken))
{
throw new PlatformAdminException("Tenant owner already exists.", "tenant_owner_exists");
}
var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking()
.SingleOrDefaultAsync(value =>
value.ActorUserId == actor.UserId &&
value.Scope == "platform.tenant.create" &&
value.IdempotencyKey == idempotencyKey,
cancellationToken);
if (existingRequest is not null)
{
if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal))
{
throw new PlatformAdminException(
"Idempotency key was already used with a different request.",
"idempotency_conflict");
}
return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken);
}
if (!command.AllowTemporaryPassword && !string.IsNullOrWhiteSpace(command.TemporaryPassword))
{
throw new PlatformAdminException(
"Temporary passwords are not allowed outside Development.",
"tenant_owner_temporary_password_not_allowed");
}
var tenantId = Guid.NewGuid();
dbContext.PlatformOperationIdempotencies.Add(new PlatformOperationIdempotency
{
ActorUserId = actor.UserId,
Scope = "platform.tenant.create",
IdempotencyKey = idempotencyKey,
RequestHash = requestHash,
ResourceId = tenantId
});
await dbContext.SaveChangesAsync(cancellationToken);
var slug = NormalizeCode(command.Slug);
if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken))
{
throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists");
}
var ownerEmail = Normalize(command.OwnerEmail);
var ownerPhone = Normalize(command.OwnerPhone);
var ownerIdentifier = ownerEmail ?? ownerPhone;
if (ownerIdentifier is null)
{
throw new PlatformAdminException("Owner email or phone is required.", "tenant_owner_identifier_required");
}
if (await dbContext.Users.AnyAsync(user =>
(ownerEmail != null && user.NormalizedEmail == ownerEmail.ToUpperInvariant()) ||
(ownerPhone != null && user.Phone == ownerPhone), cancellationToken))
{
throw new PlatformAdminException("Tenant owner already exists.", "tenant_owner_exists");
}
var tenant = new Tenant
SaasOfferingVersion? initialVersion = null;
if (command.InitialOfferingVersionId.HasValue)
{
initialVersion = await dbContext.SaasOfferingVersions.AsNoTracking()
.SingleOrDefaultAsync(value =>
value.Id == command.InitialOfferingVersionId &&
value.Status == SaasOfferingVersionStatus.Published,
cancellationToken)
?? throw new PlatformAdminException("Initial offering version was not found or published.", "saas_offering_version_not_found");
var offeringType = await dbContext.SaasOfferings.AsNoTracking()
.Where(value => value.Id == initialVersion.OfferingId)
.Select(value => value.Type)
.SingleAsync(cancellationToken);
if (offeringType != SaasOfferingType.BasePlan)
{
throw new PlatformAdminException("Initial offering must be a base plan.", "saas_base_offering_required");
}
}
var tenant = new Tenant
{
Id = tenantId,
Slug = slug,
Name = command.Name.Trim(),
LegalName = Normalize(command.LegalName),
Status = command.Status,
Mode = TenantMode.Saas,
BillingStatus = initialVersion is null ? command.BillingStatus : BillingStatus.Trial,
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.Tenants.Add(tenant);
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
{
Realm = AuthRealm.Tenant,
TenantId = tenant.Id
});
var owner = new User
{
Email = ownerEmail,
NormalizedEmail = ownerEmail?.ToUpperInvariant(),
UserName = ownerIdentifier,
NormalizedUserName = ownerIdentifier.ToUpperInvariant(),
Phone = ownerPhone,
PhoneNumber = ownerPhone,
Name = command.OwnerName.Trim(),
PrimaryRole = "tenant_owner",
Status = UserStatus.Active,
ForcePasswordChange = !string.IsNullOrWhiteSpace(command.TemporaryPassword),
EmailConfirmed = ownerEmail is not null,
PhoneNumberConfirmed = ownerPhone is not null
};
var userManager = provider.GetRequiredService<UserManager<User>>();
var createOwner = string.IsNullOrWhiteSpace(command.TemporaryPassword)
? await userManager.CreateAsync(owner)
: await userManager.CreateAsync(owner, command.TemporaryPassword);
if (!createOwner.Succeeded)
{
throw new PlatformAdminException(
string.Join("; ", createOwner.Errors.Select(error => error.Description)),
"tenant_owner_password_invalid");
}
tenant.OwnerUserId = owner.Id;
dbContext.TenantMemberships.Add(new TenantMembership
{
TenantId = tenant.Id,
UserId = owner.Id,
Role = TenantRole.TenantOwner,
Status = MembershipStatus.Active
});
dbContext.TenantAuthPolicies.Add(new TenantAuthPolicy
{
TenantId = tenant.Id,
AllowExternalStudentSelfRegistration = false
});
await EnsureTenantOwnerRoleAsync(dbContext, tenant.Id, owner.Id, cancellationToken);
var policy = new TenantBillingPolicy
{
TenantId = tenant.Id,
CollectionMode = command.CollectionMode,
DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider),
AutoGenerateRenewal = command.AutoGenerateRenewal,
RenewalLeadDays = Math.Clamp(command.RenewalLeadDays, 1, 90)
};
dbContext.TenantBillingPolicies.Add(policy);
DateTimeOffset? subscriptionExpiresAt = null;
if (initialVersion is not null)
{
var now = DateTimeOffset.UtcNow;
subscriptionExpiresAt = now.AddDays(Math.Clamp(command.TrialDays, 1, 365));
var subscription = new TenantSaasSubscription
{
TenantId = tenant.Id,
BaseOfferingVersionId = initialVersion.Id,
Status = TenantSaasSubscriptionStatus.Trial,
StartsAt = now,
CurrentPeriodStart = now,
CurrentPeriodEnd = subscriptionExpiresAt.Value,
LifecycleVersion = 1
};
dbContext.TenantSaasSubscriptions.Add(subscription);
dbContext.TenantSaasSubscriptionItems.Add(new TenantSaasSubscriptionItem
{
TenantId = tenant.Id,
SubscriptionId = subscription.Id,
OfferingVersionId = initialVersion.Id,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now,
EndsAt = subscriptionExpiresAt.Value
});
}
Guid? activationId = null;
string? activationToken = null;
DateTimeOffset? activationExpiresAt = null;
if (string.IsNullOrWhiteSpace(command.TemporaryPassword))
{
activationToken = Base64Url(RandomNumberGenerator.GetBytes(32));
activationExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30);
var grant = new TenantOwnerActivationGrant
{
TenantId = tenant.Id,
UserId = owner.Id,
CreatedBy = actor.UserId,
TokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(activationToken))).ToLowerInvariant(),
ExpiresAt = activationExpiresAt.Value
};
activationId = grant.Id;
dbContext.TenantOwnerActivationGrants.Add(grant);
}
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
await dbContext.SaveChangesAsync(cancellationToken);
return new PlatformTenantProvisioningResult(
ToTenantItem(tenant, 0, subscriptionExpiresAt),
owner.Id,
ownerIdentifier,
owner.ForcePasswordChange,
activationId,
activationToken,
activationExpiresAt,
false);
}, cancellationToken);
}
catch (DbUpdateException exception) when (IsPlatformOperationIdempotencyConflict(exception))
{
return await ExecuteSystemAsync("platform tenant create idempotency replay", async dbContext =>
{
Slug = slug,
Name = command.Name.Trim(),
LegalName = Normalize(command.LegalName),
Status = command.Status,
Mode = TenantMode.Saas,
BillingStatus = command.BillingStatus,
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.Tenants.Add(tenant);
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
{
Realm = AuthRealm.Tenant,
TenantId = tenant.Id
});
var owner = new User
{
Email = ownerEmail,
NormalizedEmail = ownerEmail?.ToUpperInvariant(),
UserName = ownerIdentifier,
NormalizedUserName = ownerIdentifier.ToUpperInvariant(),
Phone = ownerPhone,
PhoneNumber = ownerPhone,
Name = command.OwnerName.Trim(),
PrimaryRole = "tenant_owner",
Status = UserStatus.Active,
ForcePasswordChange = true,
EmailConfirmed = ownerEmail is not null,
PhoneNumberConfirmed = ownerPhone is not null
};
var createOwner = await provider.GetRequiredService<UserManager<User>>()
.CreateAsync(owner, command.TemporaryPassword);
if (!createOwner.Succeeded)
{
throw new PlatformAdminException(
string.Join("; ", createOwner.Errors.Select(error => error.Description)),
"tenant_owner_password_invalid");
}
tenant.OwnerUserId = owner.Id;
dbContext.TenantMemberships.Add(new TenantMembership
{
TenantId = tenant.Id,
UserId = owner.Id,
Role = TenantRole.TenantOwner,
Status = MembershipStatus.Active
});
dbContext.TenantAuthPolicies.Add(new TenantAuthPolicy
{
TenantId = tenant.Id,
AllowExternalStudentSelfRegistration = false
});
await EnsureTenantOwnerRoleAsync(dbContext, tenant.Id, owner.Id, cancellationToken);
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new PlatformTenantProvisioningResult(
ToTenantItem(tenant, 0, null),
owner.Id,
ownerIdentifier,
true);
}, cancellationToken);
var existingRequest = await dbContext.PlatformOperationIdempotencies.AsNoTracking()
.SingleAsync(value => value.ActorUserId == actor.UserId &&
value.Scope == "platform.tenant.create" &&
value.IdempotencyKey == idempotencyKey,
cancellationToken);
if (!string.Equals(existingRequest.RequestHash, requestHash, StringComparison.Ordinal))
{
throw new PlatformAdminException(
"Idempotency key was already used with a different request.",
"idempotency_conflict");
}
return await ProvisioningReplayResultAsync(dbContext, existingRequest.ResourceId, cancellationToken);
}, cancellationToken);
}
}
public async Task<PlatformTenantItem> UpdateTenantStatusAsync(
@@ -241,15 +382,12 @@ internal sealed class PlatformAdminService(
.SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
var fromStatus = tenant.Status;
var fromBilling = tenant.BillingStatus;
tenant.Status = command.Status;
tenant.BillingStatus = command.BillingStatus;
AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new
{
FromStatus = fromStatus,
ToStatus = tenant.Status,
FromBillingStatus = fromBilling,
ToBillingStatus = tenant.BillingStatus,
BillingStatus = tenant.BillingStatus,
command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
@@ -302,6 +440,59 @@ internal sealed class PlatformAdminService(
}, cancellationToken);
}
public async Task<TenantBillingPolicyItem> GetTenantBillingPolicyAsync(
PlatformAdminActor actor,
Guid tenantId,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant billing policy get", async dbContext =>
{
await RequireTenantAsync(dbContext, tenantId, cancellationToken);
var policy = await dbContext.TenantBillingPolicies.AsNoTracking()
.SingleOrDefaultAsync(value => value.TenantId == tenantId, cancellationToken)
?? new TenantBillingPolicy { TenantId = tenantId };
return ToBillingPolicyItem(policy);
}, cancellationToken);
}
public async Task<TenantBillingPolicyItem> UpsertTenantBillingPolicyAsync(
PlatformAdminActor actor,
UpsertTenantBillingPolicyCommand command,
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
return await ExecuteSystemAsync("platform tenant billing policy upsert", async dbContext =>
{
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
if (string.IsNullOrWhiteSpace(command.Reason))
{
throw new PlatformAdminException("Billing policy change reason is required.", "platform_billing_reason_required");
}
var policy = await dbContext.TenantBillingPolicies
.SingleOrDefaultAsync(value => value.TenantId == command.TenantId, cancellationToken);
if (policy is null)
{
policy = new TenantBillingPolicy { TenantId = command.TenantId };
dbContext.TenantBillingPolicies.Add(policy);
}
policy.CollectionMode = command.CollectionMode;
policy.DefaultPaymentProvider = NormalizeCode(command.DefaultPaymentProvider);
policy.AutoGenerateRenewal = command.AutoGenerateRenewal;
policy.RenewalLeadDays = Math.Clamp(command.RenewalLeadDays, 1, 90);
AddAudit(dbContext, actor, "platform.tenant.billing_policy.updated", command.TenantId, new
{
policy.CollectionMode,
policy.DefaultPaymentProvider,
policy.AutoGenerateRenewal,
policy.RenewalLeadDays,
command.Reason
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToBillingPolicyItem(policy);
}, cancellationToken);
}
public async Task<PlatformDomainList> GetDomainsAsync(
PlatformAdminActor actor,
PlatformAdminQuery query,
@@ -720,6 +911,49 @@ internal sealed class PlatformAdminService(
}, cancellationToken);
}
public Task<PlatformBillingDunningEventItem> AcknowledgeBillingDunningEventAsync(
PlatformAdminActor actor,
ResolvePlatformBillingDunningEventCommand command,
CancellationToken cancellationToken = default) =>
ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Acknowledged, cancellationToken);
public Task<PlatformBillingDunningEventItem> IgnoreBillingDunningEventAsync(
PlatformAdminActor actor,
ResolvePlatformBillingDunningEventCommand command,
CancellationToken cancellationToken = default) =>
ResolveBillingDunningEventAsync(actor, command, PlatformBillingDunningNotificationStatus.Ignored, cancellationToken);
private async Task<PlatformBillingDunningEventItem> ResolveBillingDunningEventAsync(
PlatformAdminActor actor,
ResolvePlatformBillingDunningEventCommand command,
PlatformBillingDunningNotificationStatus status,
CancellationToken cancellationToken)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
return await ExecuteSystemAsync("platform dunning event resolve", async dbContext =>
{
var item = await dbContext.PlatformBillingDunningNotificationEvents
.SingleOrDefaultAsync(value => value.Id == command.EventId, cancellationToken)
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
if (item.Status == PlatformBillingDunningNotificationStatus.Processing ||
item.Status is PlatformBillingDunningNotificationStatus.Acknowledged or PlatformBillingDunningNotificationStatus.Ignored)
{
throw new PlatformAdminException("Platform dunning event cannot be resolved from its current status.", "dunning_event_status_invalid");
}
var fromStatus = item.Status;
item.Status = status;
item.NextAttemptAt = null;
AddAudit(dbContext, actor,
status == PlatformBillingDunningNotificationStatus.Acknowledged
? "platform.billing_dunning_event.acknowledged"
: "platform.billing_dunning_event.ignored",
item.Id,
new { item.TenantId, FromStatus = fromStatus, ToStatus = status, command.Reason });
await dbContext.SaveChangesAsync(cancellationToken);
return ToDunningEventItem(item);
}, cancellationToken);
}
private async Task AssertPlatformPermissionAsync(
PlatformAdminActor actor,
string permissionCode,
@@ -732,6 +966,45 @@ internal sealed class PlatformAdminService(
}
}
private static bool IsPlatformOperationIdempotencyConflict(DbUpdateException exception) =>
exception.InnerException is PostgresException
{
SqlState: PostgresErrorCodes.UniqueViolation,
ConstraintName: { } constraintName
} && constraintName.StartsWith(
"ix_platform_operation_idempotencies_actor_user_id_scope_",
StringComparison.Ordinal);
private static async Task<PlatformTenantProvisioningResult> ProvisioningReplayResultAsync(
TikuDbContext dbContext,
Guid tenantId,
CancellationToken cancellationToken)
{
var tenant = await dbContext.Tenants.AsNoTracking()
.SingleAsync(value => value.Id == tenantId, cancellationToken);
var ownerId = tenant.OwnerUserId
?? throw new PlatformAdminException("Tenant owner was not found.", "tenant_owner_not_found");
var owner = await dbContext.Users.AsNoTracking().SingleAsync(value => value.Id == ownerId, cancellationToken);
var activation = await dbContext.TenantOwnerActivationGrants.AsNoTracking()
.Where(value => value.TenantId == tenant.Id && value.UserId == ownerId && value.ConsumedAt == null)
.OrderByDescending(value => value.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
var subscriptionExpiresAt = await dbContext.TenantSaasSubscriptions.AsNoTracking()
.Where(value => value.TenantId == tenant.Id)
.OrderByDescending(value => value.CurrentPeriodEnd)
.Select(value => (DateTimeOffset?)value.CurrentPeriodEnd)
.FirstOrDefaultAsync(cancellationToken);
return new PlatformTenantProvisioningResult(
ToTenantItem(tenant, 0, subscriptionExpiresAt),
ownerId,
owner.Email ?? owner.Phone ?? owner.UserName ?? ownerId.ToString(),
owner.ForcePasswordChange,
activation?.Id,
null,
activation?.ExpiresAt,
true);
}
private Task<TResult> ExecuteSystemAsync<TResult>(
string reason,
Func<TikuDbContext, Task<TResult>> operation,
@@ -833,6 +1106,16 @@ internal sealed class PlatformAdminService(
profile.BankAccountMasked,
profile.Metadata);
private static TenantBillingPolicyItem ToBillingPolicyItem(TenantBillingPolicy policy) =>
new(
policy.TenantId,
policy.CollectionMode,
policy.DefaultPaymentProvider,
policy.AutoGenerateRenewal,
policy.RenewalLeadDays,
policy.CreatedAt,
policy.UpdatedAt);
private static PlatformBillingDunningChannelItem ToDunningChannelItem(PlatformBillingDunningNotificationChannel channel) =>
new(
channel.Id,
@@ -889,6 +1172,37 @@ internal sealed class PlatformAdminService(
private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant();
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string Required(string? value, string name) =>
string.IsNullOrWhiteSpace(value)
? throw new PlatformAdminException($"{name} is required.", "idempotency_key_required")
: value.Trim();
private static string ProvisioningRequestHash(CreatePlatformTenantCommand command)
{
var payload = JsonSerializer.Serialize(new
{
command.Slug,
command.Name,
command.LegalName,
command.Status,
command.BillingStatus,
command.Metadata,
command.OwnerEmail,
command.OwnerPhone,
command.OwnerName,
HasTemporaryPassword = !string.IsNullOrWhiteSpace(command.TemporaryPassword),
command.InitialOfferingVersionId,
command.TrialDays,
command.CollectionMode,
command.DefaultPaymentProvider,
command.AutoGenerateRenewal,
command.RenewalLeadDays
});
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();
}
private static string Base64Url(byte[] value) =>
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static JsonElement JsonObjectOrDefault(JsonElement value) =>
value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
@@ -953,11 +1267,12 @@ internal sealed class PlatformAdminService(
Guid ownerUserId,
CancellationToken cancellationToken)
{
var featureCodes = SaasFeatureCatalog.All.ToArray();
var existingFeatureCodes = await dbContext.SaasFeatures
.Where(value => SaasFeatureCatalog.All.Contains(value.Code))
.Where(value => featureCodes.Contains(value.Code))
.Select(value => value.Code)
.ToArrayAsync(cancellationToken);
dbContext.SaasFeatures.AddRange(SaasFeatureCatalog.All
dbContext.SaasFeatures.AddRange(featureCodes
.Except(existingFeatureCodes, StringComparer.Ordinal)
.Select((code, index) => new SaasFeature
{
@@ -987,11 +1302,12 @@ internal sealed class PlatformAdminService(
RequiredFeatureCode = PermissionModuleCatalog.RequiredFeatures[code]
}));
var tenantPermissionCodes = BackendPermissions.Tenant.ToArray();
var existingPermissionCodes = await dbContext.BackendPermissions
.Where(value => BackendPermissions.Tenant.Contains(value.Code))
.Where(value => tenantPermissionCodes.Contains(value.Code))
.Select(value => value.Code)
.ToArrayAsync(cancellationToken);
dbContext.BackendPermissions.AddRange(BackendPermissions.Tenant
dbContext.BackendPermissions.AddRange(tenantPermissionCodes
.Except(existingPermissionCodes, StringComparer.Ordinal)
.Select(code => new BackendPermission
{
@@ -1013,7 +1329,7 @@ internal sealed class PlatformAdminService(
DataScope = JsonSerializer.SerializeToElement(new { mode = "all" })
};
dbContext.TenantBackendRoles.Add(role);
dbContext.TenantBackendRolePermissions.AddRange(BackendPermissions.Tenant.Select(code => new TenantBackendRolePermission
dbContext.TenantBackendRolePermissions.AddRange(tenantPermissionCodes.Select(code => new TenantBackendRolePermission
{
TenantId = tenantId,
RoleId = role.Id,