using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.TenantAdmin; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.TenantAdmin; public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantAdminDirectService { public async Task GetClassesAsync( TenantAdminActor actor, TenantAdminClassFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.TenantClasses.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.RegionId.HasValue) { query = query.Where(item => item.RegionId == filter.RegionId.Value); } if (!string.IsNullOrWhiteSpace(filter.Status)) { query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_class_status")); } else { query = query.Where(item => item.Status != TenantRecordStatus.Archived); } if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(item => item.Name.Contains(keyword) || (item.Code != null && item.Code.Contains(keyword))); } var items = await query .OrderBy(item => item.SortOrder) .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => new { Class = item, RegionName = dbContext.Regions .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId) .Select(region => region.Name) .FirstOrDefault(), StudentCount = dbContext.TenantClassMembers.Count(member => member.TenantId == actor.TenantId && member.ClassId == item.Id && member.Status == TenantClassMemberStatus.Active && member.MemberType == TenantClassMemberType.Student), StaffCount = dbContext.TenantClassMembers.Count(member => member.TenantId == actor.TenantId && member.ClassId == item.Id && member.Status == TenantClassMemberStatus.Active && member.MemberType != TenantClassMemberType.Student) }) .ToArrayAsync(cancellationToken); return new TenantAdminClassList( items.Select(item => ToClassItem(item.Class, item.RegionName, item.StudentCount, item.StaffCount)).ToArray(), Scoped: false); } public async Task> UpsertClassAsync( TenantAdminActor actor, UpsertTenantAdminClassCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertReferenceAsync(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); var item = await ResolveTenantEntityAsync(dbContext.TenantClasses, actor.TenantId, command.Id, command.LegacyId, cancellationToken); var isNew = item is null; item ??= new TenantClass { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, CreatedBy = actor.UserId }; item.RegionId = command.RegionId; item.LegacyId = Normalize(command.LegacyId); item.Code = Normalize(command.Code); item.Name = command.Name.Trim(); item.Description = Normalize(command.Description); item.Status = ParseEnum(command.Status, TenantRecordStatus.Active, "invalid_class_status"); item.SortOrder = command.Order ?? item.SortOrder; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; if (isNew) { dbContext.TenantClasses.Add(item); } await AddAuditAsync(actor, "tenant.class.upserted", "tenant_classes", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); var regionName = item.RegionId.HasValue ? await dbContext.Regions .Where(region => region.TenantId == actor.TenantId && region.Id == item.RegionId.Value) .Select(region => region.Name) .FirstOrDefaultAsync(cancellationToken) : null; return new ContentManagementResult(ToClassItem(item, regionName, 0, 0)); } public async Task> DisableClassAsync( TenantAdminActor actor, Guid classId, CancellationToken cancellationToken = default) { var item = await dbContext.TenantClasses .FirstOrDefaultAsync(entity => entity.TenantId == actor.TenantId && entity.Id == classId, cancellationToken); if (item is null) { throw new TenantAdminDirectException("Class was not found.", "class_not_found"); } item.Status = TenantRecordStatus.Disabled; item.UpdatedBy = actor.UserId; await AddAuditAsync(actor, "tenant.class.disabled", "tenant_classes", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToClassItem(item, null, 0, 0)); } public async Task> GetClassMembersAsync( TenantAdminActor actor, TenantAdminClassMemberFilter filter, CancellationToken cancellationToken = default) { await AssertClassAsync(actor.TenantId, filter.ClassId, cancellationToken); var query = dbContext.TenantClassMembers.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.ClassId == filter.ClassId); if (!string.IsNullOrWhiteSpace(filter.MemberType)) { query = query.Where(item => item.MemberType == ParseEnum(filter.MemberType, "invalid_class_member_type")); } query = query.Where(item => item.Status == ParseEnum(filter.Status, TenantClassMemberStatus.Active, "invalid_class_member_status")); var items = await query .OrderBy(item => item.MemberType == TenantClassMemberType.HeadTeacher ? 0 : item.MemberType == TenantClassMemberType.Teacher ? 1 : item.MemberType == TenantClassMemberType.Assistant ? 2 : 9) .ThenBy(item => item.JoinedAt) .Take(ResolveLimit(filter.Limit)) .Join( dbContext.Users.AsNoTracking(), member => member.UserId, user => user.Id, (member, user) => ToClassMemberItem(member, ToUserSummary(user))) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> UpsertClassMemberAsync( TenantAdminActor actor, UpsertTenantAdminClassMemberCommand command, CancellationToken cancellationToken = default) { await AssertClassAsync(actor.TenantId, command.ClassId, cancellationToken); 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); await EnsureMembershipAsync(actor.TenantId, user.Id, memberType == TenantClassMemberType.Student ? TenantRole.Student : TenantRole.Teacher, cancellationToken); if (memberType == TenantClassMemberType.Student) { await EnsureStudentProfileAsync(actor.TenantId, user.Id, null, null, null, null, JsonDefaults.Object(), JsonDefaults.Object(), JsonDefaults.Object(), cancellationToken); } var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => member.TenantId == actor.TenantId && member.ClassId == command.ClassId && member.UserId == user.Id && member.MemberType == memberType, cancellationToken); var isNew = item is null; item ??= new TenantClassMember { TenantId = actor.TenantId, ClassId = command.ClassId, UserId = user.Id, MemberType = memberType, CreatedBy = actor.UserId }; item.Status = status; item.LeftAt = status is TenantClassMemberStatus.Active ? null : DateTimeOffset.UtcNow; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; if (isNew) { dbContext.TenantClassMembers.Add(item); } await AddAuditAsync(actor, "tenant.class_member.upserted", "tenant_class_members", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToClassMemberItem(item, ToUserSummary(user))); } public async Task> RemoveClassMemberAsync( TenantAdminActor actor, Guid classMemberId, CancellationToken cancellationToken = default) { var item = await dbContext.TenantClassMembers .FirstOrDefaultAsync(member => member.TenantId == actor.TenantId && member.Id == classMemberId, cancellationToken); if (item is null) { throw new TenantAdminDirectException("Class member was not found.", "class_member_not_found"); } var user = await dbContext.Users.SingleAsync(user => user.Id == item.UserId, cancellationToken); item.Status = TenantClassMemberStatus.Removed; item.LeftAt = DateTimeOffset.UtcNow; item.UpdatedBy = actor.UserId; await AddAuditAsync(actor, "tenant.class_member.removed", "tenant_class_members", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToClassMemberItem(item, ToUserSummary(user))); } public async Task GetStudentsAsync( TenantAdminActor actor, TenantAdminStudentFilter filter, CancellationToken cancellationToken = default) { var status = ParseEnum(filter.Status, MembershipStatus.Active, "invalid_student_status"); var query = dbContext.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Role == TenantRole.Student && item.Status == status); if (filter.ClassId.HasValue) { await AssertClassAsync(actor.TenantId, 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(), Scoped: false); } public async Task> UpsertStudentAsync( TenantAdminActor actor, UpsertTenantAdminStudentCommand command, CancellationToken cancellationToken = default) { 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); var user = await ResolveUserAsync(command.User, "student", cancellationToken); 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); 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 status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_student_status"); var membership = await dbContext.TenantMemberships .FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.Role == TenantRole.Student, cancellationToken); if (membership is null) { throw new TenantAdminDirectException("Student membership was not found.", "student_not_found"); } membership.Status = status; if (status != MembershipStatus.Active) { var now = DateTimeOffset.UtcNow; var sessions = await dbContext.AuthSessions .Where(session => session.TenantId == actor.TenantId && session.UserId == command.UserId && session.RevokedAt == null) .ToArrayAsync(cancellationToken); foreach (var session in sessions) { session.RevokedAt = now; } } await AddAuditAsync(actor, "tenant.student.status_updated", "tenant_memberships", membership.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult( new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt)); } public async Task> GetStudentNotesAsync( TenantAdminActor actor, TenantAdminStudentActivityFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.TenantStudentNotes.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.StudentUserId.HasValue) { query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); } var items = await query .OrderByDescending(item => item.IsPinned) .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => ToNoteItem(item)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> UpsertStudentNoteAsync( TenantAdminActor actor, UpsertTenantAdminStudentNoteCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Content); await AssertStudentAsync(actor.TenantId, command.StudentUserId, cancellationToken); var item = command.Id.HasValue ? await dbContext.TenantStudentNotes.FirstOrDefaultAsync(note => note.TenantId == actor.TenantId && note.Id == command.Id.Value, cancellationToken) : null; var isNew = item is null; item ??= new TenantStudentNote { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, StudentUserId = command.StudentUserId, CreatedBy = actor.UserId }; item.NoteType = ParseEnum(command.NoteType, StudentNoteType.General, "invalid_student_note_type"); item.Content = command.Content.Trim(); item.Visibility = ParseEnum(command.Visibility, StudentNoteVisibility.TenantStaff, "invalid_student_note_visibility"); item.IsPinned = command.IsPinned ?? item.IsPinned; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; if (isNew) { dbContext.TenantStudentNotes.Add(item); } await AddAuditAsync(actor, "tenant.student_note.upserted", "tenant_student_notes", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToNoteItem(item)); } public async Task> GetStudentFollowupsAsync( TenantAdminActor actor, TenantAdminStudentActivityFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.TenantStudentFollowups.AsNoTracking().Where(item => item.TenantId == actor.TenantId); if (filter.StudentUserId.HasValue) { query = query.Where(item => item.StudentUserId == filter.StudentUserId.Value); } if (!string.IsNullOrWhiteSpace(filter.Status)) { query = query.Where(item => item.Status == ParseEnum(filter.Status, "invalid_student_followup_status")); } var items = await query .OrderBy(item => item.Status == StudentFollowupStatus.Done || item.Status == StudentFollowupStatus.Cancelled) .ThenBy(item => item.DueAt ?? DateTimeOffset.MaxValue) .ThenByDescending(item => item.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(item => ToFollowupItem(item)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> UpsertStudentFollowupAsync( TenantAdminActor actor, UpsertTenantAdminStudentFollowupCommand command, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); await AssertStudentAsync(actor.TenantId, command.StudentUserId, cancellationToken); await AssertClassAsync(actor.TenantId, command.ClassId, cancellationToken); await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken); var item = command.Id.HasValue ? await dbContext.TenantStudentFollowups.FirstOrDefaultAsync(followup => followup.TenantId == actor.TenantId && followup.Id == command.Id.Value, cancellationToken) : null; var isNew = item is null; item ??= new TenantStudentFollowup { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId, StudentUserId = command.StudentUserId, CreatedBy = actor.UserId }; item.AssignedToUserId = command.AssignedToUserId; item.ClassId = command.ClassId; item.Title = command.Title.Trim(); item.Description = Normalize(command.Description); item.FollowupType = ParseEnum(command.FollowupType, StudentFollowupType.Learning, "invalid_student_followup_type"); item.Priority = ParseEnum(command.Priority, StudentFollowupPriority.Normal, "invalid_student_followup_priority"); item.Status = ParseEnum(command.Status, StudentFollowupStatus.Open, "invalid_student_followup_status"); item.DueAt = command.DueAt; item.CompletedAt = item.Status == StudentFollowupStatus.Done ? DateTimeOffset.UtcNow : null; item.CompletedBy = item.Status == StudentFollowupStatus.Done ? actor.UserId : null; item.Metadata = JsonObjectOrDefault(command.Metadata); item.UpdatedBy = actor.UserId; if (isNew) { dbContext.TenantStudentFollowups.Add(item); } await AddAuditAsync(actor, "tenant.student_followup.upserted", "tenant_student_followups", item.Id, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToFollowupItem(item)); } private async Task ResolveUserAsync(UserLookupCommand command, string primaryRole, CancellationToken cancellationToken) { User? user = null; if (command.UserId.HasValue) { user = await dbContext.Users.FirstOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken); if (user is null) { throw new TenantAdminDirectException("User was not found.", "user_not_found"); } } else { var phone = Normalize(command.Phone); var email = Normalize(command.Email); var username = Normalize(command.Username); user = await dbContext.Users.FirstOrDefaultAsync(item => (phone != null && item.Phone == phone) || (email != null && item.Email == email) || (username != null && item.Username == username), cancellationToken); if (user is null) { if (phone is null && email is null && username is null && Normalize(command.Name) is null) { throw new TenantAdminDirectException("userId, phone, email, username or name is required.", "user_required"); } user = new User { Username = username ?? phone ?? email, Email = email, Phone = phone, Name = Normalize(command.Name) ?? username ?? phone ?? email, PrimaryRole = primaryRole, RawProfile = JsonDefaults.Object() }; dbContext.Users.Add(user); } } user.Username = Normalize(command.Username) ?? user.Username; user.Email = Normalize(command.Email) ?? user.Email; user.Phone = Normalize(command.Phone) ?? user.Phone; user.Name = Normalize(command.Name) ?? user.Name; user.AvatarUrl = Normalize(command.AvatarUrl) ?? user.AvatarUrl; user.PrimaryRole = string.IsNullOrWhiteSpace(user.PrimaryRole) ? primaryRole : user.PrimaryRole; return user; } private async Task EnsureMembershipAsync( Guid tenantId, Guid userId, TenantRole role, CancellationToken cancellationToken) { var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == userId && item.Role == role, cancellationToken); if (membership is null) { membership = new TenantMembership { TenantId = tenantId, UserId = userId, Role = role, Status = MembershipStatus.Active, Permissions = JsonDefaults.Object() }; dbContext.TenantMemberships.Add(membership); } else { membership.Status = MembershipStatus.Active; } return membership; } private async Task EnsureStudentProfileAsync( Guid tenantId, Guid userId, Guid? regionId, Guid? schoolId, Guid? majorId, string? avatarPreset, JsonElement stats, JsonElement progress, JsonElement moduleSelections, CancellationToken cancellationToken) { var profile = await dbContext.StudentProfiles.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == userId, cancellationToken); if (profile is null) { profile = new StudentProfile { TenantId = tenantId, UserId = userId, Stats = JsonDefaults.Object(), Progress = JsonDefaults.Object(), ModuleSelections = JsonDefaults.Object(), RecentActivities = JsonDefaults.Array() }; dbContext.StudentProfiles.Add(profile); } profile.RegionId = regionId ?? profile.RegionId; profile.SelectedSchoolId = schoolId ?? profile.SelectedSchoolId; profile.SelectedMajorId = majorId ?? profile.SelectedMajorId; profile.AvatarPreset = avatarPreset ?? profile.AvatarPreset; profile.Stats = JsonObjectOrDefault(stats); profile.Progress = JsonObjectOrDefault(progress); profile.ModuleSelections = JsonObjectOrDefault(moduleSelections); return profile; } private async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) { var exists = await dbContext.TenantMemberships.AnyAsync( item => item.TenantId == tenantId && item.UserId == userId && item.Role == TenantRole.Student, cancellationToken); if (!exists) { throw new TenantAdminDirectException("Student was not found.", "student_not_found"); } } private async Task AssertTenantMemberAsync(Guid tenantId, Guid? userId, CancellationToken cancellationToken) { if (!userId.HasValue) { return; } var exists = await dbContext.TenantMemberships.AnyAsync( item => item.TenantId == tenantId && item.UserId == userId.Value && item.Status == MembershipStatus.Active, cancellationToken); if (!exists) { throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found"); } } private async Task AssertClassAsync(Guid tenantId, Guid? classId, CancellationToken cancellationToken) { if (!classId.HasValue) { return; } var exists = await dbContext.TenantClasses.AnyAsync( item => item.TenantId == tenantId && item.Id == classId.Value, cancellationToken); if (!exists) { throw new TenantAdminDirectException("Class was not found.", "class_not_found"); } } private async Task AssertReferenceAsync( Guid tenantId, Guid? id, string code, CancellationToken cancellationToken) where TEntity : TenantEntity { if (!id.HasValue) { return; } var exists = await dbContext.Set().AnyAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); if (!exists) { throw new TenantAdminDirectException("Referenced entity was not found in this tenant.", code); } } private async Task ResolveTenantEntityAsync( DbSet set, Guid tenantId, Guid? id, string? legacyId, CancellationToken cancellationToken) where TEntity : AuditableTenantEntity { if (id.HasValue) { return await set.FirstOrDefaultAsync(entity => entity.TenantId == tenantId && entity.Id == id.Value, cancellationToken); } legacyId = Normalize(legacyId); if (legacyId is null) { return null; } return typeof(TEntity).GetProperty("LegacyId") is null ? null : await set.FirstOrDefaultAsync( entity => entity.TenantId == tenantId && EF.Property(entity, "LegacyId") == legacyId, cancellationToken); } private async Task AddAuditAsync( TenantAdminActor actor, string action, string targetType, Guid targetId, CancellationToken cancellationToken) { dbContext.AuditLogs.Add(new AuditLog { TenantId = actor.TenantId, ActorUserId = actor.UserId, Action = action, TargetType = targetType, TargetId = targetId.ToString(), Details = JsonDefaults.Object() }); await Task.CompletedTask.WaitAsync(cancellationToken); } private static TenantAdminClassItem ToClassItem(TenantClass item, string? regionName, int studentCount, int staffCount) { return new TenantAdminClassItem( item.Id, item.RegionId, regionName, item.LegacyId, item.Code, item.Name, item.Description, item.Status, item.SortOrder, item.Metadata, studentCount, staffCount, item.CreatedAt, item.UpdatedAt); } private static TenantAdminClassMemberItem ToClassMemberItem(TenantClassMember item, TenantAdminUserSummary user) { return new TenantAdminClassMemberItem( item.Id, item.ClassId, item.UserId, item.MemberType, item.Status, item.JoinedAt, item.LeftAt, item.Metadata, user); } private static TenantAdminStudentItem ToStudentItem( TenantMembership membership, User user, StudentProfile? profile, IReadOnlyDictionary regions, IReadOnlyDictionary schools, IReadOnlyDictionary majors, IReadOnlyCollection classes) { return new TenantAdminStudentItem( membership.Id, membership.UserId, membership.Status, ToUserSummary(user), profile?.Id, profile?.AvatarPreset ?? "male", profile?.RegionId, profile?.RegionId is Guid regionId && regions.TryGetValue(regionId, out var regionName) ? regionName : null, profile?.SelectedSchoolId, profile?.SelectedSchoolId is Guid schoolId && schools.TryGetValue(schoolId, out var schoolName) ? schoolName : null, profile?.SelectedMajorId, profile?.SelectedMajorId is Guid majorId && majors.TryGetValue(majorId, out var majorName) ? majorName : null, profile?.Stats ?? JsonDefaults.Object(), profile?.Progress ?? JsonDefaults.Object(), profile?.ModuleSelections ?? JsonDefaults.Object(), classes, membership.CreatedAt, membership.UpdatedAt); } private static TenantAdminUserSummary ToUserSummary(User user) { return new TenantAdminUserSummary( user.Id, user.Username, user.Email, user.Phone, user.Name, user.AvatarUrl, user.PrimaryRole); } private static TenantAdminStudentNoteItem ToNoteItem(TenantStudentNote item) { return new TenantAdminStudentNoteItem( item.Id, item.StudentUserId, item.NoteType, item.Content, item.Visibility, item.IsPinned, item.Metadata, item.CreatedBy, item.UpdatedBy, item.CreatedAt, item.UpdatedAt); } private static TenantAdminStudentFollowupItem ToFollowupItem(TenantStudentFollowup item) { return new TenantAdminStudentFollowupItem( item.Id, item.StudentUserId, item.AssignedToUserId, item.ClassId, item.Title, item.Description, item.FollowupType, item.Priority, item.Status, item.DueAt, item.CompletedAt, item.CompletedBy, item.Metadata, item.CreatedBy, item.UpdatedBy, item.CreatedAt, item.UpdatedAt); } private static int ResolveLimit(int? limit) { return Math.Clamp(limit ?? 100, 1, 500); } private static string? Normalize(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static JsonElement JsonObjectOrDefault(JsonElement value) { return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); } private static TEnum ParseEnum(string? value, TEnum fallback, string code) where TEnum : struct, Enum { return string.IsNullOrWhiteSpace(value) ? fallback : ParseEnum(value, code); } private static TEnum ParseEnum(string value, string code) where TEnum : struct, Enum { var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal); foreach (var candidate in Enum.GetValues()) { if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase)) { return candidate; } } throw new TenantAdminDirectException("Enum value was invalid.", code); } }