378 lines
18 KiB
C#
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);
|
|
}
|
|
}
|