Files
tiku-backend.net/Tiku.Infrastructure/TenantAdmin/Students/TenantStudentService.cs

378 lines
18 KiB
C#

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;
internal sealed class TenantStudentService(TenantAdminServiceDependencies dependencies)
: TenantAdminServiceBase(dependencies), ITenantStudentService
{
public async Task<TenantAdminStudentList> 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<TenantAdminStudentClassSummary>);
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<ContentManagementResult<TenantAdminStudentItem>> UpsertStudentAsync(
TenantAdminActor actor,
UpsertTenantAdminStudentCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
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))
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<TenantAdminStudentItem>(
ToStudentItem(
membership,
user,
profile,
new Dictionary<Guid, string>(),
new Dictionary<Guid, string>(),
new Dictionary<Guid, string>(),
[]));
}
public async Task<ContentManagementResult<TenantAdminStudentStatusItem>> 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<TenantAdminStudentStatusItem>(
new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status,
membership.UpdatedAt));
}
public async Task<TenantAdminStudentImportPreview> 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<TenantAdminStudentImportResult> 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<TenantAdminBulkOperationResult> 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<Guid>();
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<TenantAdminBulkOperationResult> BulkUpdateStudentStatusAsync(
TenantAdminActor actor,
TenantAdminBulkStatusCommand command,
CancellationToken cancellationToken = default)
{
var failed = new List<Guid>();
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);
}
}