feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -30,7 +30,8 @@ public sealed class TenantAdminDirectService(
|
||||
INotificationProvider notificationProvider,
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
IAuthSessionStore sessionStore,
|
||||
ISecurityEventPublisher securityEventPublisher) : ITenantAdminDirectService
|
||||
ISecurityEventPublisher securityEventPublisher,
|
||||
IFeatureAccessService featureAccessService) : ITenantAdminDirectService
|
||||
{
|
||||
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
|
||||
TenantAdminActor actor,
|
||||
@@ -307,6 +308,9 @@ public sealed class TenantAdminDirectService(
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
await AssertClassAsync(actor, scope, command.ClassId, cancellationToken);
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var memberType = ParseEnum(command.MemberType, TenantClassMemberType.Student, "invalid_class_member_type");
|
||||
var status = ParseEnum(command.Status, TenantClassMemberStatus.Active, "invalid_class_member_status");
|
||||
var user = await ResolveUserAsync(command.User, memberType == TenantClassMemberType.Student ? "student" : "teacher", cancellationToken);
|
||||
@@ -342,6 +346,10 @@ public sealed class TenantAdminDirectService(
|
||||
|
||||
await AddAuditAsync(actor, "tenant.class_member.upserted", "tenant_class_members", item.Id, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
return new ContentManagementResult<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
|
||||
}
|
||||
|
||||
@@ -502,6 +510,9 @@ public sealed class TenantAdminDirectService(
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Major>(actor.TenantId, command.SelectedMajorId, "major_not_found", cancellationToken);
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var user = await ResolveUserAsync(command.User, "student", cancellationToken);
|
||||
if (!scope.AllowsResource(actor.UserId, user.Id, command.RegionId))
|
||||
{
|
||||
@@ -527,6 +538,10 @@ public sealed class TenantAdminDirectService(
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student.upserted", "student_profiles", profile.Id, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return new ContentManagementResult<TenantAdminStudentItem>(
|
||||
ToStudentItem(
|
||||
@@ -572,6 +587,21 @@ public sealed class TenantAdminDirectService(
|
||||
throw new TenantAdminDirectException("Student membership was not found.", "student_not_found");
|
||||
}
|
||||
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var wasCounted = await IsUserCountedForMetricAsync(
|
||||
actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken);
|
||||
var otherMembershipCounted = await IsUserCountedForMetricAsync(
|
||||
actor.TenantId, membership.UserId, SaasQuotaMetricCatalog.StudentCount, cancellationToken, membership.Id);
|
||||
var willBeCounted = otherMembershipCounted || status == MembershipStatus.Active;
|
||||
if (!wasCounted && willBeCounted)
|
||||
{
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.StudentCount,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
membership.Status = status;
|
||||
if (status != MembershipStatus.Active)
|
||||
{
|
||||
@@ -581,6 +611,18 @@ public sealed class TenantAdminDirectService(
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student.status_updated", "tenant_memberships", membership.Id, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (wasCounted && !willBeCounted)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.StudentCount,
|
||||
1,
|
||||
cancellationToken);
|
||||
}
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
return new ContentManagementResult<TenantAdminStudentStatusItem>(
|
||||
new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt));
|
||||
}
|
||||
@@ -607,6 +649,9 @@ public sealed class TenantAdminDirectService(
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var previewItems = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken);
|
||||
var invalidItems = previewItems.Where(item => !item.Valid).ToArray();
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var createdOrUpdated = 0;
|
||||
var classAssigned = 0;
|
||||
var rowNo = 0;
|
||||
@@ -654,6 +699,10 @@ public sealed class TenantAdminDirectService(
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student.imported", "student_profiles", actor.TenantId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems);
|
||||
}
|
||||
|
||||
@@ -1127,6 +1176,9 @@ public sealed class TenantAdminDirectService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var role = ParseEnum(command.Role, TenantRole.Student, "invalid_member_role");
|
||||
var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_member_status");
|
||||
await AssertGrantableAsync(actor, role, cancellationToken);
|
||||
@@ -1157,11 +1209,37 @@ public sealed class TenantAdminDirectService(
|
||||
|
||||
var isNew = membership is null;
|
||||
var previousStatus = membership?.Status.ToString() ?? "none";
|
||||
var wasStaffCounted = await IsUserCountedForMetricAsync(
|
||||
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken);
|
||||
var wasStudentCounted = await IsUserCountedForMetricAsync(
|
||||
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken);
|
||||
Guid? excludedMembershipId = isNew ? null : membership!.Id;
|
||||
var otherStaffCounted = await IsUserCountedForMetricAsync(
|
||||
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken, excludedMembershipId);
|
||||
var otherStudentCounted = await IsUserCountedForMetricAsync(
|
||||
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken, excludedMembershipId);
|
||||
var willStaffBeCounted = otherStaffCounted || (status == MembershipStatus.Active && role != TenantRole.Student);
|
||||
var willStudentBeCounted = otherStudentCounted || (status == MembershipStatus.Active && role == TenantRole.Student);
|
||||
if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner)
|
||||
{
|
||||
throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required");
|
||||
}
|
||||
|
||||
if (!wasStaffCounted && willStaffBeCounted)
|
||||
{
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.StaffCount,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
if (!wasStudentCounted && willStudentBeCounted)
|
||||
{
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.StudentCount,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
membership ??= new TenantMembership
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -1189,6 +1267,26 @@ public sealed class TenantAdminDirectService(
|
||||
actor.TenantId, user.Id, previousStatus, status.ToString(),
|
||||
$"tenant-member-{membership.Id:N}", cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (wasStaffCounted && !willStaffBeCounted)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.StaffCount,
|
||||
1,
|
||||
cancellationToken);
|
||||
}
|
||||
if (wasStudentCounted && !willStudentBeCounted)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.StudentCount,
|
||||
1,
|
||||
cancellationToken);
|
||||
}
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||||
}
|
||||
|
||||
@@ -1198,6 +1296,9 @@ public sealed class TenantAdminDirectService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == membershipId,
|
||||
cancellationToken);
|
||||
@@ -1213,6 +1314,11 @@ public sealed class TenantAdminDirectService(
|
||||
|
||||
await AssertGrantableAsync(actor, membership.Role, cancellationToken);
|
||||
var previousStatus = membership.Status;
|
||||
var metricCode = QuotaMetricForRole(membership.Role);
|
||||
var wasCounted = await IsUserCountedForMetricAsync(
|
||||
actor.TenantId, membership.UserId, metricCode, cancellationToken);
|
||||
var otherMembershipCounted = await IsUserCountedForMetricAsync(
|
||||
actor.TenantId, membership.UserId, metricCode, cancellationToken, membership.Id);
|
||||
membership.Status = MembershipStatus.Disabled;
|
||||
await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken);
|
||||
await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken);
|
||||
@@ -1220,6 +1326,18 @@ public sealed class TenantAdminDirectService(
|
||||
actor.TenantId, membership.UserId, previousStatus.ToString(), MembershipStatus.Disabled.ToString(),
|
||||
$"tenant-member-{membership.Id:N}", cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (previousStatus == MembershipStatus.Active && wasCounted && !otherMembershipCounted)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
actor.TenantId,
|
||||
metricCode,
|
||||
1,
|
||||
cancellationToken);
|
||||
}
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||||
}
|
||||
@@ -1995,6 +2113,14 @@ public sealed class TenantAdminDirectService(
|
||||
cancellationToken);
|
||||
if (membership is null)
|
||||
{
|
||||
var metricCode = QuotaMetricForRole(role);
|
||||
if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken))
|
||||
{
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
tenantId,
|
||||
metricCode,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
membership = new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
@@ -2006,12 +2132,48 @@ public sealed class TenantAdminDirectService(
|
||||
}
|
||||
else
|
||||
{
|
||||
if (membership.Status != MembershipStatus.Active)
|
||||
{
|
||||
var metricCode = QuotaMetricForRole(role);
|
||||
if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken))
|
||||
{
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
tenantId,
|
||||
metricCode,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
membership.Status = MembershipStatus.Active;
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
private static string QuotaMetricForRole(TenantRole role) => role == TenantRole.Student
|
||||
? SaasQuotaMetricCatalog.StudentCount
|
||||
: SaasQuotaMetricCatalog.StaffCount;
|
||||
|
||||
private Task<bool> IsUserCountedForMetricAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
string metricCode,
|
||||
CancellationToken cancellationToken,
|
||||
Guid? excludedMembershipId = null)
|
||||
{
|
||||
var query = dbContext.TenantMemberships.AsNoTracking().Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.UserId == userId &&
|
||||
item.Status == MembershipStatus.Active);
|
||||
if (excludedMembershipId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.Id != excludedMembershipId.Value);
|
||||
}
|
||||
|
||||
return metricCode == SaasQuotaMetricCatalog.StudentCount
|
||||
? query.AnyAsync(item => item.Role == TenantRole.Student, cancellationToken)
|
||||
: query.AnyAsync(item => item.Role != TenantRole.Student, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<StudentProfile> EnsureStudentProfileAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
@@ -2347,7 +2509,7 @@ public sealed class TenantAdminDirectService(
|
||||
Code = permissionCode,
|
||||
Name = permissionCode,
|
||||
Area = BackendPermissionArea.Tenant,
|
||||
Module = permissionCode.Split(':')[1],
|
||||
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode),
|
||||
IsSystem = true
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user