using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.TenantAdmin; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.TenantAdmin; public sealed partial class TenantAdminDirectService { public async Task> GetStudentNotesAsync( TenantAdminActor actor, TenantAdminStudentActivityFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var query = dbContext.TenantStudentNotes.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.StudentUserId == actor.UserId || item.CreatedBy == actor.UserId, item => dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.StudentUserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))); 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) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Content); await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken); TenantStudentNote? item = null; if (command.Id.HasValue) { var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); item = await dbContext.TenantStudentNotes .Where(note => note.TenantId == actor.TenantId && note.Id == command.Id.Value) .ApplyDataScope( scope, note => note.StudentUserId == actor.UserId || note.CreatedBy == actor.UserId, note => dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == note.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == note.StudentUserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))) .FirstOrDefaultAsync(cancellationToken); if (item is null) throw new TenantAdminDirectException("Student note was not found.", "student_note_not_found"); } 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 scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); var query = dbContext.TenantStudentFollowups.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.StudentUserId == actor.UserId || item.AssignedToUserId == actor.UserId || item.CreatedBy == actor.UserId, item => (item.ClassId.HasValue && classIds.Contains(item.ClassId.Value)) || dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == item.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == item.StudentUserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))); 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) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); await AssertStudentAsync(actor, scope, command.StudentUserId, cancellationToken); await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken); TenantStudentFollowup? item = null; if (command.Id.HasValue) { var regionIds = scope.RegionIds.ToArray(); var classIds = scope.ClassIds.ToArray(); item = await dbContext.TenantStudentFollowups .Where(followup => followup.TenantId == actor.TenantId && followup.Id == command.Id.Value) .ApplyDataScope( scope, followup => followup.StudentUserId == actor.UserId || followup.AssignedToUserId == actor.UserId || followup.CreatedBy == actor.UserId, followup => (followup.ClassId.HasValue && classIds.Contains(followup.ClassId.Value)) || dbContext.StudentProfiles.Any(profile => profile.TenantId == actor.TenantId && profile.UserId == followup.StudentUserId && profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) || dbContext.TenantClassMembers.Any(member => member.TenantId == actor.TenantId && member.UserId == followup.StudentUserId && member.Status == TenantClassMemberStatus.Active && classIds.Contains(member.ClassId))) .FirstOrDefaultAsync(cancellationToken); if (item is null) throw new TenantAdminDirectException("Student followup was not found.", "student_followup_not_found"); } 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)); } }