using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.Security; using Tiku.Application.TenantAdmin; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.TenantAdmin; public sealed partial class TenantAdminDirectService { public async Task> GetMembersAsync( TenantAdminActor actor, TenantAdminMemberFilter filter, CancellationToken cancellationToken = default) { await RequireAllDataScopeAsync(actor, cancellationToken); var query = dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (!string.IsNullOrWhiteSpace(filter.Role)) query = query.Where(item => item.Role == ParseEnum(filter.Role, "invalid_member_role")); if (!string.IsNullOrWhiteSpace(filter.Status)) query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_member_status")); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => dbContext.Users.Any(user => user.Id == item.UserId && ((user.UserName != null && user.UserName.Contains(keyword)) || (user.Phone != null && user.Phone.Contains(keyword)) || (user.Email != null && user.Email.Contains(keyword)) || (user.Name != null && user.Name.Contains(keyword))))); } var memberships = await query .OrderBy(item => item.Role == TenantRole.TenantOwner ? 0 : item.Role == TenantRole.TenantAdmin ? 1 : item.Role == TenantRole.TenantOperator ? 2 : item.Role == TenantRole.Teacher ? 3 : 9) .ThenBy(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .ToArrayAsync(cancellationToken); var userIds = memberships.Select(item => item.UserId).ToArray(); var users = await dbContext.Users.AsNoTracking() .Where(user => userIds.Contains(user.Id)) .ToDictionaryAsync(user => user.Id, cancellationToken); return new CatalogList(memberships.Select(item => { users.TryGetValue(item.UserId, out var user); return ToMemberItem(item, user ?? new User { Id = item.UserId }); }).ToArray()); } public async Task> UpsertMemberAsync( TenantAdminActor actor, UpsertTenantAdminMemberCommand command, 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); var primaryRole = Normalize(command.PrimaryRole) ?? RoleToPrimaryRole(role); var user = await ResolveUserAsync(command.User, primaryRole, cancellationToken); if (user.Id == actor.UserId && status == MembershipStatus.Disabled) throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self"); TenantMembership? membership = null; if (command.MembershipId.HasValue) { membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.MembershipId.Value, cancellationToken); if (membership is null) throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); } else { membership = await dbContext.TenantMemberships.FirstOrDefaultAsync( item => item.TenantId == actor.TenantId && item.UserId == user.Id && item.Role == role, cancellationToken); } 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, UserId = user.Id }; membership.UserId = user.Id; membership.Role = role; membership.Status = status; if (isNew) dbContext.TenantMemberships.Add(membership); if (status != MembershipStatus.Active) await RevokeSessionsAsync(actor.TenantId, user.Id, cancellationToken); else if (role == TenantRole.TenantOwner) await EnsureTenantOwnerBackendRoleAsync(actor.TenantId, user.Id, cancellationToken); await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, 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); await authorizationStateInvalidator.InvalidateMembershipAsync( actor.TenantId, membership.UserId, cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, actor.TenantId, cancellationToken); return new ContentManagementResult(ToMemberItem(membership, user)); } public async Task> DisableMemberAsync( TenantAdminActor actor, Guid membershipId, 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); if (membership is null) throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); if (membership.UserId == actor.UserId) throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self"); 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); 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); await authorizationStateInvalidator.InvalidateMembershipAsync( actor.TenantId, membership.UserId, cancellationToken); var user = await dbContext.Users.AsNoTracking() .SingleAsync(item => item.Id == membership.UserId, cancellationToken); return new ContentManagementResult(ToMemberItem(membership, user)); } }