using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Content; using Tiku.Application.Security; using Tiku.Application.TenantAdmin; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.TenantAdmin; public sealed partial class TenantAdminDirectService { public async Task GetStudentsAsync( TenantAdminActor actor, TenantAdminStudentFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var status = ParseEnum(filter.Status, MembershipStatus.Active, "invalid_student_status"); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var query = dbContext.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Role == TenantRole.Student && item.Status == status) .ApplyDataScope( scope, item => item.UserId == actor.UserId, item => dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))); if (filter.ClassId.HasValue) { await AssertClassAsync(actor, scope, filter.ClassId.Value, cancellationToken); query = query.Where(item => dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.ClassId == filter.ClassId.Value && member.UserId == item.UserId && member.MemberType == TenantClassMemberType.Student && member.Status == TenantClassMemberStatus.Active)); } if (filter.RegionId.HasValue) query = query.Where(item => dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId && profile.RegionId == filter.RegionId.Value)); 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 .OrderByDescending(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); var profiles = await dbContext.StudentProfiles.AsNoTracking() .Where(profile => profile.TenantId == actor.TenantId && userIds.Contains(profile.UserId)) .ToDictionaryAsync(profile => profile.UserId, cancellationToken); var regions = await dbContext.Regions.AsNoTracking() .Where(region => region.TenantId == actor.TenantId) .ToDictionaryAsync(region => region.Id, region => region.Name, cancellationToken); var schools = await dbContext.Schools.AsNoTracking() .Where(school => school.TenantId == actor.TenantId) .ToDictionaryAsync(school => school.Id, school => school.Name, cancellationToken); var majors = await dbContext.Majors.AsNoTracking() .Where(major => major.TenantId == actor.TenantId) .ToDictionaryAsync(major => major.Id, major => major.Name, cancellationToken); var classes = await ( from member in dbContext.TenantClassMembers.AsNoTracking() join tenantClass in dbContext.TenantClasses.AsNoTracking() on member.ClassId equals tenantClass.Id where member.TenantId == actor.TenantId && tenantClass.TenantId == actor.TenantId && userIds.Contains(member.UserId) && member.Status == TenantClassMemberStatus.Active && member.MemberType == TenantClassMemberType.Student orderby tenantClass.SortOrder, tenantClass.CreatedAt descending select new { member.UserId, member.MemberType, member.JoinedAt, ClassId = tenantClass.Id, tenantClass.Name, tenantClass.Code }) .ToArrayAsync(cancellationToken); var classLookup = classes .GroupBy(item => item.UserId) .ToDictionary( group => group.Key, group => group.Select(item => new TenantAdminStudentClassSummary( item.ClassId, item.Name, item.Code, item.MemberType, item.JoinedAt)).ToArray() as IReadOnlyCollection); return new TenantAdminStudentList( memberships.Select(membership => { users.TryGetValue(membership.UserId, out var user); profiles.TryGetValue(membership.UserId, out var profile); classLookup.TryGetValue(membership.UserId, out var studentClasses); return ToStudentItem( membership, user ?? new User { Id = membership.UserId }, profile, regions, schools, majors, studentClasses ?? []); }).ToArray(), scope.Mode != DataScopeMode.All); } public async Task> UpsertStudentAsync( TenantAdminActor actor, UpsertTenantAdminStudentCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken); await AssertReferenceAsync(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)) throw new TenantAdminDirectException("Student was not found.", "student_not_found"); if (command.RawProfile.ValueKind == JsonValueKind.Object) user.RawProfile = command.RawProfile.Clone(); var membership = await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken); var profile = await EnsureStudentProfileAsync( actor.TenantId, user.Id, command.RegionId, command.SelectedSchoolId, command.SelectedMajorId, Normalize(command.AvatarPreset), command.Stats, command.Progress, command.ModuleSelections, cancellationToken); 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( ToStudentItem( membership, user, profile, new Dictionary(), new Dictionary(), new Dictionary(), [])); } public async Task> UpdateStudentStatusAsync( TenantAdminActor actor, UpdateTenantAdminStudentStatusCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_student_status"); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var membership = await dbContext.TenantMemberships .Where(item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.Role == TenantRole.Student) .ApplyDataScope( scope, item => item.UserId == actor.UserId, item => dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.UserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.UserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))) .FirstOrDefaultAsync(cancellationToken); if (membership is null) 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) await sessionStore.RevokeRealmAsync( command.UserId, AuthRealm.Tenant, actor.TenantId, "membership_disabled", cancellationToken); 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); await authorizationStateInvalidator.InvalidateMembershipAsync( actor.TenantId, membership.UserId, cancellationToken); return new ContentManagementResult( new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt)); } public async Task PreviewStudentImportAsync( TenantAdminActor actor, TenantAdminStudentImportCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var items = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken); return new TenantAdminStudentImportPreview( command.Rows.Count, items.Count(item => item.Valid), items.Count(item => !item.Valid), items); } public async Task ImportStudentsAsync( TenantAdminActor actor, TenantAdminStudentImportCommand command, CancellationToken cancellationToken = default) { 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; foreach (var row in command.Rows) { rowNo++; if (invalidItems.Any(item => item.RowNo == rowNo)) continue; var user = await ResolveUserAsync(row.User, "student", cancellationToken); if (row.RawProfile.ValueKind == JsonValueKind.Object) user.RawProfile = row.RawProfile.Clone(); await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken); await EnsureStudentProfileAsync( actor.TenantId, user.Id, row.RegionId, null, null, Normalize(row.AvatarPreset), JsonDefaults.Object(), JsonDefaults.Object(), JsonDefaults.Object(), cancellationToken); createdOrUpdated++; if (row.ClassId.HasValue) { await UpsertClassMemberCoreAsync( actor, row.ClassId.Value, user.Id, TenantClassMemberType.Student, TenantClassMemberStatus.Active, JsonSerializer.SerializeToElement(new { source = "student_import" }), cancellationToken); classAssigned++; } } 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); } public async Task BulkAssignClassAsync( TenantAdminActor actor, TenantAdminBulkAssignClassCommand command, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); var failed = new List(); var succeeded = 0; foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) try { await AssertStudentAsync(actor, scope, userId, cancellationToken); await UpsertClassMemberCoreAsync( actor, command.ClassId, userId, TenantClassMemberType.Student, TenantClassMemberStatus.Active, JsonSerializer.SerializeToElement(new { source = "bulk_assign_class" }), cancellationToken); succeeded++; } catch (TenantAdminDirectException) { failed.Add(userId); } await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); } public async Task BulkUpdateStudentStatusAsync( TenantAdminActor actor, TenantAdminBulkStatusCommand command, CancellationToken cancellationToken = default) { var failed = new List(); var succeeded = 0; foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) try { await UpdateStudentStatusAsync( actor, new UpdateTenantAdminStudentStatusCommand(userId, command.Status, command.Reason), cancellationToken); succeeded++; } catch (TenantAdminDirectException) { failed.Add(userId); } await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); } }