forked from xiongyuxing/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -2,7 +2,9 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -10,7 +12,8 @@ namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
public sealed class TenantFrontendConfigService(
|
||||
TikuDbContext dbContext,
|
||||
IMemoryCache cache) : ITenantFrontendConfigService
|
||||
IMemoryCache cache,
|
||||
IFeatureAccessService featureAccessService) : ITenantFrontendConfigService
|
||||
{
|
||||
private static readonly TimeSpan RuntimeCacheDuration = TimeSpan.FromMinutes(2);
|
||||
|
||||
@@ -90,6 +93,16 @@ public sealed class TenantFrontendConfigService(
|
||||
item => item.Id == tenantId && item.Status == TenantStatus.Active,
|
||||
cancellationToken)
|
||||
?? throw new TenantFrontendConfigException("tenant_not_found", "Active tenant was not found.");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var hasActiveSubscription = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(item =>
|
||||
item.TenantId == tenantId &&
|
||||
(item.Status == TenantSaasSubscriptionStatus.Trial || item.Status == TenantSaasSubscriptionStatus.Active) &&
|
||||
item.StartsAt <= now && item.CurrentPeriodEnd > now,
|
||||
cancellationToken);
|
||||
if (!hasActiveSubscription)
|
||||
{
|
||||
throw new TenantFrontendConfigException("subscription_inactive", "An active tenant subscription is required.");
|
||||
}
|
||||
var config = await dbContext.TenantFrontendConfigs.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken)
|
||||
?? CreateDefault(tenantId);
|
||||
@@ -102,7 +115,20 @@ public sealed class TenantFrontendConfigService(
|
||||
config.PublishedTheme.Clone(),
|
||||
config.PublishedFeatures.Clone(),
|
||||
config.PublishedNavigation.Clone(),
|
||||
config.PublishedHomeModules.Clone());
|
||||
config.PublishedHomeModules.Clone(),
|
||||
(await featureAccessService.GetEnabledFeaturesAsync(tenantId, FeatureAccessOperation.Read, cancellationToken))
|
||||
.Where(code => code != SaasFeatureCatalog.CoreBackoffice)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
(await dbContext.TenantAuthPolicies.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId)
|
||||
.Select(item => item.AllowedStudentLoginMethods)
|
||||
.SingleOrDefaultAsync(cancellationToken) ?? ["password"])
|
||||
.Select(value => value.Trim().ToLowerInvariant())
|
||||
.Where(value => value.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray());
|
||||
cache.Set(CacheKey(tenantId), result, RuntimeCacheDuration);
|
||||
return result;
|
||||
}
|
||||
|
||||
81
Tiku.Infrastructure/Tenancy/TenantOnboardingService.cs
Normal file
81
Tiku.Infrastructure/Tenancy/TenantOnboardingService.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
internal sealed class TenantOnboardingService(
|
||||
TikuDbContext dbContext,
|
||||
IFeatureAccessService featureAccessService) : ITenantOnboardingService
|
||||
{
|
||||
public async Task<TenantOnboardingStatus> GetStatusAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenant = await dbContext.Tenants.AsNoTracking().SingleOrDefaultAsync(value => value.Id == tenantId, cancellationToken)
|
||||
?? throw new TenantExternalProviderException("Tenant was not found.", "tenant_not_found");
|
||||
var ownerActivated = tenant.OwnerUserId.HasValue && await (
|
||||
from membership in dbContext.TenantMemberships.AsNoTracking()
|
||||
join user in dbContext.Users.AsNoTracking() on membership.UserId equals user.Id
|
||||
where membership.TenantId == tenantId && membership.UserId == tenant.OwnerUserId &&
|
||||
membership.Role == TenantRole.TenantOwner && membership.Status == MembershipStatus.Active &&
|
||||
user.Status == Tiku.Domain.Identity.UserStatus.Active && !user.ForcePasswordChange
|
||||
select membership.Id).AnyAsync(cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var subscriptionActive = await dbContext.TenantSaasSubscriptions.AsNoTracking().AnyAsync(value =>
|
||||
value.TenantId == tenantId &&
|
||||
(value.Status == TenantSaasSubscriptionStatus.Trial || value.Status == TenantSaasSubscriptionStatus.Active) &&
|
||||
value.StartsAt <= now && value.CurrentPeriodEnd > now, cancellationToken);
|
||||
var primaryDomainActive = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value =>
|
||||
value.TenantId == tenantId && value.IsPrimary && value.Status == TenantDomainStatus.Active, cancellationToken);
|
||||
var frontendPublished = await dbContext.TenantFrontendConfigs.AsNoTracking().AnyAsync(value =>
|
||||
value.TenantId == tenantId && value.PublishedAt != null, cancellationToken);
|
||||
var loginMethods = await dbContext.TenantAuthPolicies.AsNoTracking()
|
||||
.Where(value => value.TenantId == tenantId)
|
||||
.Select(value => value.AllowedStudentLoginMethods)
|
||||
.SingleOrDefaultAsync(cancellationToken) ?? ["password"];
|
||||
var hasLoginMethod = loginMethods.Any(value => !string.IsNullOrWhiteSpace(value));
|
||||
|
||||
var enabledFeatures = await featureAccessService.GetEnabledFeaturesAsync(tenantId, FeatureAccessOperation.Read, cancellationToken);
|
||||
var paymentRequired = enabledFeatures.Contains(SaasFeatureCatalog.StudentStore);
|
||||
var storageRequired = enabledFeatures.Overlaps(new[]
|
||||
{
|
||||
SaasFeatureCatalog.PrivateQuestionBank,
|
||||
SaasFeatureCatalog.Video,
|
||||
SaasFeatureCatalog.Handbook
|
||||
});
|
||||
var smsRequired = loginMethods.Any(value => string.Equals(value, "sms", StringComparison.OrdinalIgnoreCase));
|
||||
var providers = await dbContext.TenantExternalProviders.AsNoTracking()
|
||||
.Where(value => value.TenantId == tenantId && value.Status == TenantExternalProviderStatus.Active)
|
||||
.Select(value => value.Capability)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var steps = new List<TenantOnboardingStep>
|
||||
{
|
||||
new("tenant_active", true, tenant.Status == TenantStatus.Active, tenant.Status.ToString()),
|
||||
new("owner_activated", true, ownerActivated, tenant.OwnerUserId?.ToString()),
|
||||
new("subscription_active", true, subscriptionActive, null),
|
||||
new("primary_domain_active", true, primaryDomainActive, null),
|
||||
new("frontend_config_published", true, frontendPublished, null),
|
||||
new("student_login_configured", true, hasLoginMethod, string.Join(',', loginMethods)),
|
||||
ProviderStep("object_storage_provider", TenantExternalProviderCapability.ObjectStorage, storageRequired, providers),
|
||||
ProviderStep("sms_provider", TenantExternalProviderCapability.Sms, smsRequired, providers),
|
||||
ProviderStep("payment_provider", TenantExternalProviderCapability.Payment, paymentRequired, providers),
|
||||
ProviderStep("notification_provider", TenantExternalProviderCapability.Notification, false, providers)
|
||||
};
|
||||
var required = steps.Count(value => value.Required);
|
||||
var completed = steps.Count(value => value.Required && value.Completed);
|
||||
return new TenantOnboardingStatus(tenantId, completed == required, completed, required, steps);
|
||||
}
|
||||
|
||||
private static TenantOnboardingStep ProviderStep(
|
||||
string code,
|
||||
TenantExternalProviderCapability capability,
|
||||
bool required,
|
||||
IReadOnlyCollection<TenantExternalProviderCapability> providers) =>
|
||||
new(code, required, providers.Contains(capability), providers.Contains(capability) ? "active" : "missing");
|
||||
}
|
||||
Reference in New Issue
Block a user