3280 lines
141 KiB
C#
3280 lines
141 KiB
C#
using System.Text.Json;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Tiku.Application.Catalog;
|
||
using Tiku.Application.Auth;
|
||
using Tiku.Application.Content;
|
||
using Tiku.Application.Notifications;
|
||
using Tiku.Application.Security;
|
||
using Tiku.Application.Tenancy;
|
||
using Tiku.Application.TenantAdmin;
|
||
using Tiku.Domain.Catalog;
|
||
using Tiku.Domain.Common;
|
||
using Tiku.Domain.Identity;
|
||
using Tiku.Domain.Learning;
|
||
using Tiku.Domain.Operations;
|
||
using Tiku.Domain.Tenancy;
|
||
using Tiku.Infrastructure.Persistence;
|
||
using Tiku.Infrastructure.Tenancy;
|
||
using Tiku.Infrastructure.Security;
|
||
using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus;
|
||
using OrderStatus = Tiku.Domain.Commerce.OrderStatus;
|
||
using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus;
|
||
using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus;
|
||
using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus;
|
||
|
||
namespace Tiku.Infrastructure.TenantAdmin;
|
||
|
||
public sealed class TenantAdminDirectService(
|
||
TikuDbContext dbContext,
|
||
ITenantExternalProviderConfigService providerConfigService,
|
||
INotificationProvider notificationProvider,
|
||
ICurrentAccessContext currentAccessContext,
|
||
IAuthSessionStore sessionStore,
|
||
IFeatureAccessService featureAccessService,
|
||
IAuthorizationStateInvalidator authorizationStateInvalidator) : ITenantAdminDirectService
|
||
{
|
||
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var classIds = scope.ClassIds.ToArray();
|
||
var now = DateTimeOffset.UtcNow;
|
||
var today = new DateTimeOffset(now.UtcDateTime.Date, TimeSpan.Zero);
|
||
var scopedClasses = dbContext.TenantClasses.AsNoTracking()
|
||
.Where(item => item.TenantId == actor.TenantId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
item => item.CreatedBy == actor.UserId,
|
||
item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)));
|
||
var scopedStudents = dbContext.StudentProfiles.AsNoTracking()
|
||
.Where(item => item.TenantId == actor.TenantId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
item => item.UserId == actor.UserId,
|
||
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||
var scopedOrders = dbContext.Orders.AsNoTracking()
|
||
.Where(item => item.TenantId == actor.TenantId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
item => item.UserId == actor.UserId,
|
||
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||
|
||
var studentCount = await scopedStudents.CountAsync(cancellationToken);
|
||
var classCount = await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken);
|
||
var staffCount = await dbContext.TenantMemberships.AsNoTracking()
|
||
.CountAsync(item =>
|
||
item.TenantId == actor.TenantId &&
|
||
item.Status == MembershipStatus.Active &&
|
||
item.Role != TenantRole.Student,
|
||
cancellationToken);
|
||
var activePracticeCount = await dbContext.PracticeSessions.AsNoTracking()
|
||
.CountAsync(item =>
|
||
item.TenantId == actor.TenantId &&
|
||
item.FinishedAt == null &&
|
||
(!item.ExpiresAt.HasValue || item.ExpiresAt > now),
|
||
cancellationToken);
|
||
var todayPracticeCount = await dbContext.PracticeSessions.AsNoTracking()
|
||
.CountAsync(item => item.TenantId == actor.TenantId && item.StartedAt >= today, cancellationToken);
|
||
var pendingFollowupCount = await dbContext.TenantStudentFollowups.AsNoTracking()
|
||
.CountAsync(item =>
|
||
item.TenantId == actor.TenantId &&
|
||
(item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress),
|
||
cancellationToken);
|
||
var unreadNotificationCount = await dbContext.UserNotifications.AsNoTracking()
|
||
.CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, cancellationToken);
|
||
var paidOrderCount = await scopedOrders.CountAsync(item => item.Status == OrderStatus.Paid, cancellationToken);
|
||
var revenueCents = await scopedOrders
|
||
.Where(item => item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || item.Status == OrderStatus.Refunded)
|
||
.SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken);
|
||
var pendingRefundCount = await dbContext.CommerceRefundRequests.AsNoTracking()
|
||
.CountAsync(item =>
|
||
item.TenantId == actor.TenantId &&
|
||
(item.Status == CommerceRefundStatus.Requested ||
|
||
item.Status == CommerceRefundStatus.Approved ||
|
||
item.Status == CommerceRefundStatus.Processing),
|
||
cancellationToken);
|
||
var openReconciliationIssueCount = await dbContext.CommerceReconciliationIssues.AsNoTracking()
|
||
.CountAsync(item =>
|
||
item.TenantId == actor.TenantId &&
|
||
item.Status != ReconciliationIssueStatus.Resolved &&
|
||
item.Status != ReconciliationIssueStatus.Ignored,
|
||
cancellationToken);
|
||
|
||
return new TenantAdminOverviewItem(
|
||
studentCount,
|
||
classCount,
|
||
staffCount,
|
||
activePracticeCount,
|
||
todayPracticeCount,
|
||
pendingFollowupCount,
|
||
unreadNotificationCount,
|
||
paidOrderCount,
|
||
revenueCents,
|
||
pendingRefundCount,
|
||
openReconciliationIssueCount,
|
||
now);
|
||
}
|
||
|
||
public async Task<TenantAdminClassList> GetClassesAsync(
|
||
TenantAdminActor actor,
|
||
TenantAdminClassFilter filter,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var classIds = scope.ClassIds.ToArray();
|
||
var query = dbContext.TenantClasses.AsNoTracking()
|
||
.Where(item => item.TenantId == actor.TenantId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
item => item.CreatedBy == actor.UserId,
|
||
item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)));
|
||
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<TenantRecordStatus>(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: scope.Mode != DataScopeMode.All);
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminClassItem>> UpsertClassAsync(
|
||
TenantAdminActor actor,
|
||
UpsertTenantAdminClassCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||
await AssertReferenceAsync<Region>(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;
|
||
if (item is not null && !scope.AllowsResource(actor.UserId, item.CreatedBy, item.RegionId, item.Id))
|
||
{
|
||
throw new TenantAdminDirectException("Class was not found.", "class_not_found");
|
||
}
|
||
|
||
if (item is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId, command.Id))
|
||
{
|
||
throw new TenantAdminDirectException("Class was not found.", "class_not_found");
|
||
}
|
||
|
||
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<TenantAdminClassItem>(ToClassItem(item, regionName, 0, 0));
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminClassItem>> DisableClassAsync(
|
||
TenantAdminActor actor,
|
||
Guid classId,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var classIds = scope.ClassIds.ToArray();
|
||
var item = await dbContext.TenantClasses
|
||
.Where(entity => entity.TenantId == actor.TenantId && entity.Id == classId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
entity => entity.CreatedBy == actor.UserId,
|
||
entity => classIds.Contains(entity.Id) || (entity.RegionId.HasValue && regionIds.Contains(entity.RegionId.Value)))
|
||
.FirstOrDefaultAsync(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<TenantAdminClassItem>(ToClassItem(item, null, 0, 0));
|
||
}
|
||
|
||
public async Task<CatalogList<TenantAdminClassMemberItem>> GetClassMembersAsync(
|
||
TenantAdminActor actor,
|
||
TenantAdminClassMemberFilter filter,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
await AssertClassAsync(actor, scope, filter.ClassId, cancellationToken);
|
||
var classIds = scope.ClassIds.ToArray();
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var query = dbContext.TenantClassMembers.AsNoTracking()
|
||
.Where(item => item.TenantId == actor.TenantId && item.ClassId == filter.ClassId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
item => item.UserId == actor.UserId || item.CreatedBy == actor.UserId,
|
||
item => classIds.Contains(item.ClassId) || dbContext.TenantClasses.Any(tenantClass =>
|
||
tenantClass.TenantId == actor.TenantId &&
|
||
tenantClass.Id == item.ClassId &&
|
||
tenantClass.RegionId.HasValue &&
|
||
regionIds.Contains(tenantClass.RegionId.Value)));
|
||
|
||
if (!string.IsNullOrWhiteSpace(filter.MemberType))
|
||
{
|
||
query = query.Where(item => item.MemberType == ParseEnum<TenantClassMemberType>(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<TenantAdminClassMemberItem>(items);
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminClassMemberItem>> UpsertClassMemberAsync(
|
||
TenantAdminActor actor,
|
||
UpsertTenantAdminClassMemberCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
await AssertClassAsync(actor, scope, command.ClassId, cancellationToken);
|
||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||
: null;
|
||
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);
|
||
if (transaction is not null)
|
||
{
|
||
await transaction.CommitAsync(cancellationToken);
|
||
}
|
||
return new ContentManagementResult<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminClassMemberItem>> RemoveClassMemberAsync(
|
||
TenantAdminActor actor,
|
||
Guid classMemberId,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
var classIds = scope.ClassIds.ToArray();
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var item = await dbContext.TenantClassMembers
|
||
.Where(member => member.TenantId == actor.TenantId && member.Id == classMemberId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
member => member.UserId == actor.UserId || member.CreatedBy == actor.UserId,
|
||
member => classIds.Contains(member.ClassId) || dbContext.TenantClasses.Any(tenantClass =>
|
||
tenantClass.TenantId == actor.TenantId &&
|
||
tenantClass.Id == member.ClassId &&
|
||
tenantClass.RegionId.HasValue &&
|
||
regionIds.Contains(tenantClass.RegionId.Value)))
|
||
.FirstOrDefaultAsync(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<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
|
||
}
|
||
|
||
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(),
|
||
Scoped: 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);
|
||
}
|
||
|
||
public async Task<CatalogList<TenantSupervisionRuleItem>> GetSupervisionRulesAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireDataScopeAsync(actor, cancellationToken);
|
||
return new CatalogList<TenantSupervisionRuleItem>(await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken));
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantSupervisionRuleItem>> UpsertSupervisionRuleAsync(
|
||
TenantAdminActor actor,
|
||
UpsertTenantSupervisionRuleCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireDataScopeAsync(actor, cancellationToken);
|
||
var code = NormalizeCode(command.Code);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
|
||
var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)).ToList();
|
||
var item = new TenantSupervisionRuleItem(
|
||
code,
|
||
command.Title.Trim(),
|
||
command.Enabled,
|
||
command.DaysWithoutCheckIn,
|
||
command.MaxQuestionsAnsweredToday,
|
||
Normalize(command.FollowupType) ?? StudentFollowupType.Risk.ToString(),
|
||
Normalize(command.Priority) ?? StudentFollowupPriority.High.ToString(),
|
||
JsonObjectOrDefault(command.Metadata));
|
||
rules.RemoveAll(rule => string.Equals(rule.Code, code, StringComparison.Ordinal));
|
||
rules.Add(item);
|
||
await SaveSupervisionRulesCoreAsync(actor.TenantId, rules.OrderBy(rule => rule.Code).ToArray(), cancellationToken);
|
||
await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
return new ContentManagementResult<TenantSupervisionRuleItem>(item);
|
||
}
|
||
|
||
public async Task<TenantSupervisionPreview> PreviewSupervisionAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
return new TenantSupervisionPreview(await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken));
|
||
}
|
||
|
||
public async Task<TenantSupervisionGenerateResult> GenerateSupervisionFollowupsAsync(
|
||
TenantAdminActor actor,
|
||
TenantSupervisionGenerateCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken);
|
||
var riskStudents = await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken);
|
||
if (command.UserIds is { Count: > 0 })
|
||
{
|
||
var selected = command.UserIds.Where(id => id != Guid.Empty).Distinct().ToHashSet();
|
||
riskStudents = riskStudents.Where(item => selected.Contains(item.UserId)).ToArray();
|
||
}
|
||
|
||
var created = 0;
|
||
foreach (var student in riskStudents)
|
||
{
|
||
if (await dbContext.TenantStudentFollowups.AnyAsync(item =>
|
||
item.TenantId == actor.TenantId &&
|
||
item.StudentUserId == student.UserId &&
|
||
item.Status != StudentFollowupStatus.Done &&
|
||
item.FollowupType == StudentFollowupType.Risk,
|
||
cancellationToken))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
dbContext.TenantStudentFollowups.Add(new TenantStudentFollowup
|
||
{
|
||
TenantId = actor.TenantId,
|
||
StudentUserId = student.UserId,
|
||
AssignedToUserId = command.AssignedToUserId,
|
||
Title = "学习风险督导",
|
||
Description = string.Join(";", student.Reasons),
|
||
FollowupType = StudentFollowupType.Risk,
|
||
Priority = StudentFollowupPriority.High,
|
||
Status = StudentFollowupStatus.Open,
|
||
DueAt = command.DueAt,
|
||
CreatedBy = actor.UserId,
|
||
UpdatedBy = actor.UserId,
|
||
Metadata = JsonSerializer.SerializeToElement(new { ruleCodes = student.RuleCodes })
|
||
});
|
||
created++;
|
||
}
|
||
|
||
await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
return new TenantSupervisionGenerateResult(created);
|
||
}
|
||
|
||
public async Task<TenantFollowupReport> GetFollowupReportAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireDataScopeAsync(actor, cancellationToken);
|
||
var now = DateTimeOffset.UtcNow;
|
||
return new TenantFollowupReport(
|
||
await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, cancellationToken),
|
||
await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, cancellationToken),
|
||
await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, cancellationToken),
|
||
await dbContext.TenantStudentFollowups.CountAsync(item =>
|
||
item.TenantId == actor.TenantId &&
|
||
item.DueAt.HasValue &&
|
||
item.DueAt < now &&
|
||
item.Status != StudentFollowupStatus.Done &&
|
||
item.Status != StudentFollowupStatus.Cancelled,
|
||
cancellationToken));
|
||
}
|
||
|
||
public async Task<TenantFeedbackReport> GetFeedbackReportAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireDataScopeAsync(actor, cancellationToken);
|
||
return new TenantFeedbackReport(
|
||
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken),
|
||
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken),
|
||
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken),
|
||
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken),
|
||
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken));
|
||
}
|
||
|
||
public async Task<TenantPointRiskReport> GetPointRiskReportAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireDataScopeAsync(actor, cancellationToken);
|
||
var negativeScoreUsers = await dbContext.Users
|
||
.Where(user => user.Score < 0 &&
|
||
dbContext.TenantMemberships.Any(member =>
|
||
member.TenantId == actor.TenantId &&
|
||
member.UserId == user.Id &&
|
||
member.Status == MembershipStatus.Active))
|
||
.CountAsync(cancellationToken);
|
||
var since = DateTimeOffset.UtcNow.AddDays(-7);
|
||
var highClaimUsers = await dbContext.PointActivityClaims
|
||
.Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityClaimStatus.Claimed && item.ClaimedAt >= since)
|
||
.GroupBy(item => item.UserId)
|
||
.Where(group => group.Sum(item => item.Points) >= 1000)
|
||
.CountAsync(cancellationToken);
|
||
var cancelledExchangeOrders = await dbContext.PointExchangeOrders.CountAsync(
|
||
item => item.TenantId == actor.TenantId && item.Status == PointExchangeOrderStatus.Cancelled,
|
||
cancellationToken);
|
||
return new TenantPointRiskReport(negativeScoreUsers, highClaimUsers, cancelledExchangeOrders);
|
||
}
|
||
|
||
public async Task<CatalogList<TenantAdminStudentNoteItem>> 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<TenantAdminStudentNoteItem>(items);
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminStudentNoteItem>> 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<TenantAdminStudentNoteItem>(ToNoteItem(item));
|
||
}
|
||
|
||
public async Task<CatalogList<TenantAdminStudentFollowupItem>> 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<StudentFollowupStatus>(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<TenantAdminStudentFollowupItem>(items);
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminStudentFollowupItem>> 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<TenantAdminStudentFollowupItem>(ToFollowupItem(item));
|
||
}
|
||
|
||
public async Task<CatalogList<TenantAdminMemberItem>> GetMembersAsync(
|
||
TenantAdminActor actor,
|
||
TenantAdminMemberFilter filter,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
var query = dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||
if (!string.IsNullOrWhiteSpace(filter.Role))
|
||
{
|
||
query = query.Where(item => item.Role == ParseEnum<TenantRole>(filter.Role, "invalid_member_role"));
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(filter.Status))
|
||
{
|
||
query = query.Where(item => item.Status == ParseEnum<MembershipStatus>(filter.Status, "invalid_member_status"));
|
||
}
|
||
|
||
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
|
||
.OrderBy(item => item.Role == TenantRole.TenantOwner ? 0 :
|
||
item.Role == TenantRole.TenantAdmin ? 1 :
|
||
item.Role == TenantRole.TenantOperator ? 2 :
|
||
item.Role == TenantRole.Teacher ? 3 : 9)
|
||
.ThenBy(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);
|
||
|
||
return new CatalogList<TenantAdminMemberItem>(memberships.Select(item =>
|
||
{
|
||
users.TryGetValue(item.UserId, out var user);
|
||
return ToMemberItem(item, user ?? new User { Id = item.UserId });
|
||
}).ToArray());
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminMemberItem>> UpsertMemberAsync(
|
||
TenantAdminActor actor,
|
||
UpsertTenantAdminMemberCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||
: null;
|
||
var role = ParseEnum(command.Role, TenantRole.Student, "invalid_member_role");
|
||
var status = ParseEnum(command.Status, MembershipStatus.Active, "invalid_member_status");
|
||
await AssertGrantableAsync(actor, role, cancellationToken);
|
||
var primaryRole = Normalize(command.PrimaryRole) ?? RoleToPrimaryRole(role);
|
||
var user = await ResolveUserAsync(command.User, primaryRole, cancellationToken);
|
||
if (user.Id == actor.UserId && status == MembershipStatus.Disabled)
|
||
{
|
||
throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self");
|
||
}
|
||
|
||
TenantMembership? membership = null;
|
||
if (command.MembershipId.HasValue)
|
||
{
|
||
membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(
|
||
item => item.TenantId == actor.TenantId && item.Id == command.MembershipId.Value,
|
||
cancellationToken);
|
||
if (membership is null)
|
||
{
|
||
throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(
|
||
item => item.TenantId == actor.TenantId && item.UserId == user.Id && item.Role == role,
|
||
cancellationToken);
|
||
}
|
||
|
||
var isNew = membership is null;
|
||
var previousStatus = membership?.Status.ToString() ?? "none";
|
||
var wasStaffCounted = await IsUserCountedForMetricAsync(
|
||
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken);
|
||
var wasStudentCounted = await IsUserCountedForMetricAsync(
|
||
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken);
|
||
Guid? excludedMembershipId = isNew ? null : membership!.Id;
|
||
var otherStaffCounted = await IsUserCountedForMetricAsync(
|
||
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StaffCount, cancellationToken, excludedMembershipId);
|
||
var otherStudentCounted = await IsUserCountedForMetricAsync(
|
||
actor.TenantId, user.Id, SaasQuotaMetricCatalog.StudentCount, cancellationToken, excludedMembershipId);
|
||
var willStaffBeCounted = otherStaffCounted || (status == MembershipStatus.Active && role != TenantRole.Student);
|
||
var willStudentBeCounted = otherStudentCounted || (status == MembershipStatus.Active && role == TenantRole.Student);
|
||
if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner)
|
||
{
|
||
throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required");
|
||
}
|
||
|
||
if (!wasStaffCounted && willStaffBeCounted)
|
||
{
|
||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||
actor.TenantId,
|
||
SaasQuotaMetricCatalog.StaffCount,
|
||
cancellationToken: cancellationToken);
|
||
}
|
||
if (!wasStudentCounted && willStudentBeCounted)
|
||
{
|
||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||
actor.TenantId,
|
||
SaasQuotaMetricCatalog.StudentCount,
|
||
cancellationToken: cancellationToken);
|
||
}
|
||
|
||
membership ??= new TenantMembership
|
||
{
|
||
TenantId = actor.TenantId,
|
||
UserId = user.Id
|
||
};
|
||
membership.UserId = user.Id;
|
||
membership.Role = role;
|
||
membership.Status = status;
|
||
if (isNew)
|
||
{
|
||
dbContext.TenantMemberships.Add(membership);
|
||
}
|
||
|
||
if (status != MembershipStatus.Active)
|
||
{
|
||
await RevokeSessionsAsync(actor.TenantId, user.Id, cancellationToken);
|
||
}
|
||
else if (role == TenantRole.TenantOwner)
|
||
{
|
||
await EnsureTenantOwnerBackendRoleAsync(actor.TenantId, user.Id, cancellationToken);
|
||
}
|
||
|
||
await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
if (wasStaffCounted && !willStaffBeCounted)
|
||
{
|
||
await featureAccessService.ReleaseQuotaAsync(
|
||
actor.TenantId,
|
||
SaasQuotaMetricCatalog.StaffCount,
|
||
1,
|
||
cancellationToken);
|
||
}
|
||
if (wasStudentCounted && !willStudentBeCounted)
|
||
{
|
||
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);
|
||
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, actor.TenantId, cancellationToken);
|
||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminMemberItem>> DisableMemberAsync(
|
||
TenantAdminActor actor,
|
||
Guid membershipId,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||
: null;
|
||
var membership = await dbContext.TenantMemberships.FirstOrDefaultAsync(
|
||
item => item.TenantId == actor.TenantId && item.Id == membershipId,
|
||
cancellationToken);
|
||
if (membership is null)
|
||
{
|
||
throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
|
||
}
|
||
|
||
if (membership.UserId == actor.UserId)
|
||
{
|
||
throw new TenantAdminDirectException("Cannot disable your own tenant membership.", "cannot_disable_self");
|
||
}
|
||
|
||
await AssertGrantableAsync(actor, membership.Role, cancellationToken);
|
||
var previousStatus = membership.Status;
|
||
var metricCode = QuotaMetricForRole(membership.Role);
|
||
var wasCounted = await IsUserCountedForMetricAsync(
|
||
actor.TenantId, membership.UserId, metricCode, cancellationToken);
|
||
var otherMembershipCounted = await IsUserCountedForMetricAsync(
|
||
actor.TenantId, membership.UserId, metricCode, cancellationToken, membership.Id);
|
||
membership.Status = MembershipStatus.Disabled;
|
||
await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken);
|
||
await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
if (previousStatus == MembershipStatus.Active && wasCounted && !otherMembershipCounted)
|
||
{
|
||
await featureAccessService.ReleaseQuotaAsync(
|
||
actor.TenantId,
|
||
metricCode,
|
||
1,
|
||
cancellationToken);
|
||
}
|
||
if (transaction is not null)
|
||
{
|
||
await transaction.CommitAsync(cancellationToken);
|
||
}
|
||
await authorizationStateInvalidator.InvalidateMembershipAsync(
|
||
actor.TenantId, membership.UserId, cancellationToken);
|
||
var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken);
|
||
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
|
||
}
|
||
|
||
public async Task<CatalogList<TenantAdminAuditLogItem>> GetAuditLogsAsync(
|
||
TenantAdminActor actor,
|
||
TenantAdminAuditLogFilter filter,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
var query = dbContext.AuditLogs.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||
if (!string.IsNullOrWhiteSpace(filter.Action))
|
||
{
|
||
var action = filter.Action.Trim();
|
||
query = query.Where(item => item.Action.StartsWith(action));
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(filter.TargetType))
|
||
{
|
||
query = query.Where(item => item.TargetType == filter.TargetType.Trim());
|
||
}
|
||
|
||
if (filter.ActorUserId.HasValue)
|
||
{
|
||
query = query.Where(item => item.ActorUserId == filter.ActorUserId.Value);
|
||
}
|
||
|
||
var logs = await query
|
||
.OrderByDescending(item => item.CreatedAt)
|
||
.Take(ResolveLimit(filter.Limit))
|
||
.ToArrayAsync(cancellationToken);
|
||
var actorIds = logs.Where(item => item.ActorUserId.HasValue).Select(item => item.ActorUserId!.Value).ToArray();
|
||
var users = await dbContext.Users.AsNoTracking()
|
||
.Where(user => actorIds.Contains(user.Id))
|
||
.ToDictionaryAsync(user => user.Id, cancellationToken);
|
||
|
||
return new CatalogList<TenantAdminAuditLogItem>(logs.Select(item =>
|
||
{
|
||
var user = item.ActorUserId.HasValue && users.TryGetValue(item.ActorUserId.Value, out var actorUser)
|
||
? actorUser
|
||
: null;
|
||
return new TenantAdminAuditLogItem(
|
||
item.Id,
|
||
item.ActorUserId,
|
||
item.Action,
|
||
item.TargetType,
|
||
item.TargetId,
|
||
item.Details,
|
||
item.IpAddress,
|
||
item.UserAgent,
|
||
user?.Name ?? user?.UserName,
|
||
user?.Phone,
|
||
item.CreatedAt);
|
||
}).ToArray());
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantBrandingItem>> UpsertBrandingAsync(
|
||
TenantAdminActor actor,
|
||
UpsertTenantBrandingCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(command.BrandName);
|
||
var item = await dbContext.TenantBrandings.FirstOrDefaultAsync(branding => branding.TenantId == actor.TenantId, cancellationToken);
|
||
if (item is null)
|
||
{
|
||
item = new TenantBranding { TenantId = actor.TenantId };
|
||
dbContext.TenantBrandings.Add(item);
|
||
}
|
||
|
||
item.BrandName = command.BrandName.Trim();
|
||
item.ShortName = Normalize(command.ShortName);
|
||
item.Slogan = Normalize(command.Slogan);
|
||
item.OrganizationName = Normalize(command.OrganizationName);
|
||
item.LogoUrl = Normalize(command.LogoUrl);
|
||
item.FaviconUrl = Normalize(command.FaviconUrl);
|
||
item.ServiceWechat = Normalize(command.ServiceWechat);
|
||
item.ServiceAccountName = Normalize(command.ServiceAccountName);
|
||
await AddAuditAsync(actor, "tenant.branding.updated", "tenant_branding", actor.TenantId, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
return new ContentManagementResult<TenantBrandingItem>(ToBrandingItem(item));
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantSettingsItem>> UpsertSettingsAsync(
|
||
TenantAdminActor actor,
|
||
UpsertTenantSettingsCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
AssertNoSecrets(command.PublicConfig, "public_config");
|
||
var item = await dbContext.TenantSettings.FirstOrDefaultAsync(settings => settings.TenantId == actor.TenantId, cancellationToken);
|
||
if (item is null)
|
||
{
|
||
item = new TenantSettings { TenantId = actor.TenantId };
|
||
dbContext.TenantSettings.Add(item);
|
||
}
|
||
|
||
item.FeatureFlags = JsonObjectOrDefault(command.FeatureFlags);
|
||
item.AdminFeatureFlags = JsonObjectOrDefault(command.AdminFeatureFlags);
|
||
item.PublicConfig = JsonObjectOrDefault(command.PublicConfig);
|
||
await AddAuditAsync(actor, "tenant.settings.updated", "tenant_settings", actor.TenantId, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
return new ContentManagementResult<TenantSettingsItem>(ToSettingsItem(item));
|
||
}
|
||
|
||
public async Task<CatalogList<TenantThemeTemplateItem>> GetThemeTemplatesAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
await Task.CompletedTask.WaitAsync(cancellationToken);
|
||
var items = await dbContext.TenantThemeTemplates.AsNoTracking()
|
||
.Where(item => item.Status == TenantThemeTemplateStatus.Active)
|
||
.OrderBy(item => item.SortOrder)
|
||
.ThenBy(item => item.Code)
|
||
.Select(item => new TenantThemeTemplateItem(
|
||
item.Code,
|
||
item.Name,
|
||
item.Description,
|
||
item.PreviewImageUrl,
|
||
item.Theme,
|
||
item.PublicAssets,
|
||
item.SortOrder))
|
||
.ToArrayAsync(cancellationToken);
|
||
return new CatalogList<TenantThemeTemplateItem>(items);
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantThemeItem>> GetThemeAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
var item = await dbContext.TenantThemeConfigs.AsNoTracking()
|
||
.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken);
|
||
if (item is not null)
|
||
{
|
||
return new ContentManagementResult<TenantThemeItem>(ToThemeItem(item));
|
||
}
|
||
|
||
var branding = await dbContext.TenantBrandings.AsNoTracking()
|
||
.FirstOrDefaultAsync(tenantBranding => tenantBranding.TenantId == actor.TenantId, cancellationToken);
|
||
return new ContentManagementResult<TenantThemeItem>(new TenantThemeItem(
|
||
actor.TenantId,
|
||
null,
|
||
branding?.Theme ?? JsonDefaults.Object(),
|
||
branding?.PublicAssets ?? JsonDefaults.Object(),
|
||
null,
|
||
JsonDefaults.Object(),
|
||
JsonDefaults.Object(),
|
||
TenantThemeConfigStatus.Published,
|
||
null,
|
||
null,
|
||
null,
|
||
branding?.UpdatedAt ?? DateTimeOffset.UtcNow));
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantThemeItem>> PreviewThemeAsync(
|
||
TenantAdminActor actor,
|
||
PreviewTenantThemeCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode);
|
||
var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync(
|
||
item => item.Code == command.TemplateCode && item.Status == TenantThemeTemplateStatus.Active,
|
||
cancellationToken);
|
||
if (template is null)
|
||
{
|
||
throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found");
|
||
}
|
||
|
||
var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken);
|
||
if (item is null)
|
||
{
|
||
item = new TenantThemeConfig { TenantId = actor.TenantId };
|
||
dbContext.TenantThemeConfigs.Add(item);
|
||
}
|
||
|
||
item.DraftTemplateCode = template.Code;
|
||
item.DraftTheme = MergeJsonObjects(template.Theme, command.Theme);
|
||
item.DraftPublicAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets);
|
||
item.Status = TenantThemeConfigStatus.Draft;
|
||
item.DraftUpdatedBy = actor.UserId;
|
||
await AddAuditAsync(actor, "tenant.theme.previewed", "tenant_theme_configs", actor.TenantId, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
return new ContentManagementResult<TenantThemeItem>(ToThemeItem(item));
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantThemeItem>> PublishThemeAsync(
|
||
TenantAdminActor actor,
|
||
PublishTenantThemeCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
var item = await dbContext.TenantThemeConfigs.FirstOrDefaultAsync(theme => theme.TenantId == actor.TenantId, cancellationToken);
|
||
JsonElement activeTheme;
|
||
JsonElement activeAssets;
|
||
string? templateCode;
|
||
if (command.UseDraft)
|
||
{
|
||
if (item is null || string.IsNullOrWhiteSpace(item.DraftTemplateCode))
|
||
{
|
||
throw new TenantAdminDirectException("No draft theme to publish.", "theme_draft_not_found");
|
||
}
|
||
|
||
activeTheme = item.DraftTheme;
|
||
activeAssets = item.DraftPublicAssets;
|
||
templateCode = item.DraftTemplateCode;
|
||
}
|
||
else
|
||
{
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(command.TemplateCode);
|
||
var template = await dbContext.TenantThemeTemplates.FirstOrDefaultAsync(
|
||
theme => theme.Code == command.TemplateCode && theme.Status == TenantThemeTemplateStatus.Active,
|
||
cancellationToken);
|
||
if (template is null)
|
||
{
|
||
throw new TenantAdminDirectException("Theme template was not found.", "theme_template_not_found");
|
||
}
|
||
|
||
item ??= new TenantThemeConfig { TenantId = actor.TenantId };
|
||
if (dbContext.Entry(item).State == EntityState.Detached)
|
||
{
|
||
dbContext.TenantThemeConfigs.Add(item);
|
||
}
|
||
|
||
activeTheme = MergeJsonObjects(template.Theme, command.Theme);
|
||
activeAssets = MergeJsonObjects(template.PublicAssets, command.PublicAssets);
|
||
templateCode = template.Code;
|
||
}
|
||
|
||
item ??= new TenantThemeConfig { TenantId = actor.TenantId };
|
||
if (dbContext.Entry(item).State == EntityState.Detached)
|
||
{
|
||
dbContext.TenantThemeConfigs.Add(item);
|
||
}
|
||
|
||
item.ActiveTemplateCode = templateCode;
|
||
item.ActiveTheme = activeTheme.Clone();
|
||
item.ActivePublicAssets = activeAssets.Clone();
|
||
item.DraftTemplateCode = null;
|
||
item.DraftTheme = JsonDefaults.Object();
|
||
item.DraftPublicAssets = JsonDefaults.Object();
|
||
item.Status = TenantThemeConfigStatus.Published;
|
||
item.PublishedAt = DateTimeOffset.UtcNow;
|
||
item.PublishedBy = actor.UserId;
|
||
await EnsureBrandingThemeAsync(actor.TenantId, activeTheme, activeAssets, cancellationToken);
|
||
await AddAuditAsync(actor, "tenant.theme.published", "tenant_theme_configs", actor.TenantId, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
return new ContentManagementResult<TenantThemeItem>(ToThemeItem(item));
|
||
}
|
||
|
||
public async Task<CatalogList<TenantDomainItem>> GetDomainsAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
var items = await dbContext.TenantDomains.AsNoTracking()
|
||
.Where(item => item.TenantId == actor.TenantId)
|
||
.OrderByDescending(item => item.IsPrimary)
|
||
.ThenBy(item => item.CreatedAt)
|
||
.Select(item => ToDomainItem(item))
|
||
.ToArrayAsync(cancellationToken);
|
||
return new CatalogList<TenantDomainItem>(items);
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantDomainItem>> CreateDomainAsync(
|
||
TenantAdminActor actor,
|
||
CreateTenantDomainCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
TenantDomain generated;
|
||
try
|
||
{
|
||
generated = TenantDomainProvisioning.CreatePrimary(actor.TenantId, command.Host);
|
||
}
|
||
catch (ArgumentException exception)
|
||
{
|
||
throw new TenantAdminDirectException(exception.Message, "invalid_domain_host");
|
||
}
|
||
var host = generated.Host;
|
||
if (command.IsPrimary)
|
||
{
|
||
var primaryDomains = await dbContext.TenantDomains
|
||
.Where(item => item.TenantId == actor.TenantId && item.IsPrimary)
|
||
.ToArrayAsync(cancellationToken);
|
||
foreach (var domain in primaryDomains)
|
||
{
|
||
domain.IsPrimary = false;
|
||
}
|
||
}
|
||
|
||
var item = new TenantDomain
|
||
{
|
||
TenantId = actor.TenantId,
|
||
Host = host,
|
||
DomainType = ParseEnum(command.DomainType, TenantDomainType.Custom, "invalid_domain_type"),
|
||
Status = TenantDomainStatus.Pending,
|
||
IsPrimary = command.IsPrimary,
|
||
VerificationToken = generated.VerificationToken
|
||
};
|
||
dbContext.TenantDomains.Add(item);
|
||
await AddAuditAsync(actor, "tenant.domain.created", "tenant_domains", item.Id, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
return new ContentManagementResult<TenantDomainItem>(ToDomainItem(item));
|
||
}
|
||
|
||
public async Task<CatalogList<TenantIdentityProviderItem>> GetAuthProvidersAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
var items = await providerConfigService.GetProvidersAsync(
|
||
actor.TenantId,
|
||
TenantExternalProviderCapability.Identity,
|
||
cancellationToken: cancellationToken);
|
||
return new CatalogList<TenantIdentityProviderItem>(items.Select(ToAuthProviderItem).ToArray());
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantIdentityProviderItem>> UpsertAuthProviderAsync(
|
||
TenantAdminActor actor,
|
||
UpsertTenantIdentityProviderCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Provider);
|
||
var item = await providerConfigService.UpsertProviderAsync(
|
||
actor.TenantId,
|
||
new UpsertTenantExternalProviderCommand(
|
||
TenantExternalProviderCapability.Identity,
|
||
command.Provider,
|
||
ParseEnum(command.Status, TenantExternalProviderStatus.Disabled, "invalid_auth_provider_status"),
|
||
command.DisplayName,
|
||
command.SecretRef,
|
||
command.Priority,
|
||
command.ConfigPublic,
|
||
JsonObjectOrDefault(default)),
|
||
cancellationToken);
|
||
|
||
await AddAuditAsync(actor, "tenant.auth_provider.upserted", "tenant_external_providers", item.Id, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
return new ContentManagementResult<TenantIdentityProviderItem>(ToAuthProviderItem(item));
|
||
}
|
||
|
||
public async Task<CatalogList<TenantAdminBadgeItem>> GetBadgesAsync(
|
||
TenantAdminActor actor,
|
||
TenantAdminBadgeFilter filter,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
var query = dbContext.Badges.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||
if (!string.IsNullOrWhiteSpace(filter.Category))
|
||
{
|
||
query = query.Where(item => item.Category == filter.Category.Trim());
|
||
}
|
||
|
||
if (!filter.IncludeInactive)
|
||
{
|
||
query = query.Where(item => item.IsActive);
|
||
}
|
||
|
||
var items = await query
|
||
.OrderBy(item => item.SortOrder)
|
||
.ThenBy(item => item.Level ?? int.MaxValue)
|
||
.ThenByDescending(item => item.CreatedAt)
|
||
.Take(ResolveLimit(filter.Limit))
|
||
.Select(item => ToBadgeItem(item))
|
||
.ToArrayAsync(cancellationToken);
|
||
return new CatalogList<TenantAdminBadgeItem>(items);
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminBadgeItem>> UpsertBadgeAsync(
|
||
TenantAdminActor actor,
|
||
UpsertTenantAdminBadgeCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await RequireAllDataScopeAsync(actor, cancellationToken);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||
var item = await ResolveTenantEntityAsync(dbContext.Badges, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||
var isNew = item is null;
|
||
item ??= new Badge { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||
item.LegacyId = Normalize(command.LegacyId);
|
||
item.Name = command.Name.Trim();
|
||
item.Description = Normalize(command.Description);
|
||
item.Category = Normalize(command.Category) ?? "custom";
|
||
item.IconUrl = Normalize(command.IconUrl);
|
||
item.Level = command.Level;
|
||
item.UnlockType = Normalize(command.UnlockType) ?? "manual";
|
||
item.ConditionField = Normalize(command.ConditionField);
|
||
item.ConditionOperator = Normalize(command.ConditionOperator);
|
||
item.ConditionValue = command.ConditionValue;
|
||
item.ConditionExtra = JsonObjectOrDefault(command.ConditionExtra);
|
||
item.SortOrder = command.Order ?? item.SortOrder;
|
||
item.IsActive = command.IsActive ?? item.IsActive;
|
||
if (isNew)
|
||
{
|
||
dbContext.Badges.Add(item);
|
||
}
|
||
|
||
await AddAuditAsync(actor, "tenant.badge.upserted", "badges", item.Id, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
return new ContentManagementResult<TenantAdminBadgeItem>(ToBadgeItem(item));
|
||
}
|
||
|
||
public async Task<CatalogList<TenantAdminBadgeGrantItem>> GetBadgeGrantsAsync(
|
||
TenantAdminActor actor,
|
||
TenantAdminBadgeGrantFilter filter,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var classIds = scope.ClassIds.ToArray();
|
||
var query = dbContext.UserBadges.AsNoTracking()
|
||
.Where(item => item.TenantId == actor.TenantId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
item => item.UserId == actor.UserId || item.GrantedBy == actor.UserId,
|
||
item => item.UserId.HasValue &&
|
||
(dbContext.StudentProfiles.Any(profile =>
|
||
profile.TenantId == actor.TenantId &&
|
||
profile.UserId == item.UserId.Value &&
|
||
profile.RegionId.HasValue &&
|
||
regionIds.Contains(profile.RegionId.Value)) ||
|
||
dbContext.TenantClassMembers.Any(member =>
|
||
member.TenantId == actor.TenantId &&
|
||
member.UserId == item.UserId.Value &&
|
||
member.Status == TenantClassMemberStatus.Active &&
|
||
classIds.Contains(member.ClassId))));
|
||
if (filter.UserId.HasValue)
|
||
{
|
||
query = query.Where(item => item.UserId == filter.UserId.Value);
|
||
}
|
||
|
||
if (filter.BadgeId.HasValue)
|
||
{
|
||
query = query.Where(item => item.BadgeId == filter.BadgeId.Value);
|
||
}
|
||
|
||
var grants = await query
|
||
.OrderByDescending(item => item.GrantedAt ?? item.CreatedAt)
|
||
.Take(ResolveLimit(filter.Limit))
|
||
.ToArrayAsync(cancellationToken);
|
||
var userIds = grants.Select(item => item.UserId).OfType<Guid>().Concat(grants.Select(item => item.GrantedBy).OfType<Guid>()).Distinct().ToArray();
|
||
var badgeIds = grants.Select(item => item.BadgeId).OfType<Guid>().Distinct().ToArray();
|
||
var users = await dbContext.Users.AsNoTracking()
|
||
.Where(user => userIds.Contains(user.Id))
|
||
.ToDictionaryAsync(user => user.Id, cancellationToken);
|
||
var badges = await dbContext.Badges.AsNoTracking()
|
||
.Where(badge => badge.TenantId == actor.TenantId && badgeIds.Contains(badge.Id))
|
||
.ToDictionaryAsync(badge => badge.Id, cancellationToken);
|
||
|
||
return new CatalogList<TenantAdminBadgeGrantItem>(grants.Select(grant =>
|
||
ToBadgeGrantItem(
|
||
grant,
|
||
grant.UserId.HasValue && users.TryGetValue(grant.UserId.Value, out var user) ? user : null,
|
||
grant.GrantedBy.HasValue && users.TryGetValue(grant.GrantedBy.Value, out var grantedBy) ? grantedBy : null,
|
||
grant.BadgeId.HasValue && badges.TryGetValue(grant.BadgeId.Value, out var badge) ? badge : null)).ToArray());
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminBadgeGrantItem>> GrantBadgeAsync(
|
||
TenantAdminActor actor,
|
||
GrantTenantAdminBadgeCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
var badge = await dbContext.Badges.FirstOrDefaultAsync(
|
||
item => item.TenantId == actor.TenantId && item.Id == command.BadgeId,
|
||
cancellationToken);
|
||
if (badge is null)
|
||
{
|
||
throw new TenantAdminDirectException("Badge was not found.", "badge_not_found");
|
||
}
|
||
|
||
if (!badge.IsActive)
|
||
{
|
||
throw new TenantAdminDirectException("Cannot grant inactive badge.", "badge_inactive");
|
||
}
|
||
|
||
await AssertStudentAsync(actor, scope, command.UserId, cancellationToken);
|
||
var grant = await dbContext.UserBadges.FirstOrDefaultAsync(
|
||
item => item.TenantId == actor.TenantId && item.UserId == command.UserId && item.BadgeId == command.BadgeId,
|
||
cancellationToken);
|
||
var isNew = grant is null;
|
||
grant ??= new UserBadge
|
||
{
|
||
TenantId = actor.TenantId,
|
||
UserId = command.UserId,
|
||
BadgeId = command.BadgeId,
|
||
GrantedBy = actor.UserId,
|
||
GrantedAt = command.GrantedAt ?? DateTimeOffset.UtcNow
|
||
};
|
||
grant.LegacyId = Normalize(command.LegacyId) ?? grant.LegacyId;
|
||
grant.Note = Normalize(command.Note) ?? grant.Note;
|
||
grant.GrantedBy ??= actor.UserId;
|
||
grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow;
|
||
if (isNew)
|
||
{
|
||
dbContext.UserBadges.Add(grant);
|
||
}
|
||
|
||
var dedupeKey = $"badge:{grant.Id:N}";
|
||
await notificationProvider.UpsertInAppAsync(
|
||
new InAppNotificationRequest(
|
||
actor.TenantId,
|
||
command.UserId,
|
||
"badge_granted",
|
||
NotificationSeverity.Success,
|
||
$"获得勋章:{badge.Name}",
|
||
Normalize(command.Note) ?? "管理员为你发放了一枚新的学习勋章。",
|
||
actor.UserId,
|
||
"查看勋章",
|
||
"/profile?tab=badges",
|
||
"user_badges",
|
||
grant.Id,
|
||
dedupeKey,
|
||
JsonSerializer.SerializeToElement(new { badgeId = badge.Id, badgeName = badge.Name })),
|
||
cancellationToken);
|
||
|
||
await AddAuditAsync(actor, "tenant.badge.granted", "user_badges", grant.Id, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
var user = await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == command.UserId, cancellationToken);
|
||
return new ContentManagementResult<TenantAdminBadgeGrantItem>(ToBadgeGrantItem(grant, user, null, badge));
|
||
}
|
||
|
||
public async Task<CatalogList<TenantAdminNotificationItem>> GetNotificationsAsync(
|
||
TenantAdminActor actor,
|
||
TenantAdminNotificationFilter filter,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var classIds = scope.ClassIds.ToArray();
|
||
var query = dbContext.UserNotifications.AsNoTracking()
|
||
.Where(item => item.TenantId == actor.TenantId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
item => item.UserId == actor.UserId || item.CreatedBy == 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.UserId.HasValue)
|
||
{
|
||
query = query.Where(item => item.UserId == filter.UserId.Value);
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(filter.Status))
|
||
{
|
||
query = query.Where(item => item.Status == ParseEnum<NotificationStatus>(filter.Status, "invalid_notification_status"));
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(filter.NotificationType))
|
||
{
|
||
query = query.Where(item => item.NotificationType == filter.NotificationType.Trim());
|
||
}
|
||
|
||
var items = await query
|
||
.OrderByDescending(item => item.CreatedAt)
|
||
.Take(ResolveLimit(filter.Limit))
|
||
.Select(item => ToNotificationItem(item))
|
||
.ToArrayAsync(cancellationToken);
|
||
return new CatalogList<TenantAdminNotificationItem>(items);
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminNotificationItem>> UpsertNotificationAsync(
|
||
TenantAdminActor actor,
|
||
UpsertTenantAdminNotificationCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(command.NotificationType);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Message);
|
||
await AssertStudentAsync(actor, scope, command.UserId, cancellationToken);
|
||
|
||
var item = await notificationProvider.UpsertInAppAsync(
|
||
new InAppNotificationRequest(
|
||
actor.TenantId,
|
||
command.UserId,
|
||
command.NotificationType,
|
||
ParseEnum(command.Severity, NotificationSeverity.Info, "invalid_notification_severity"),
|
||
command.Title,
|
||
command.Message,
|
||
actor.UserId,
|
||
command.ActionLabel,
|
||
command.ActionPath,
|
||
command.SourceType,
|
||
command.SourceId,
|
||
command.DedupeKey,
|
||
JsonObjectOrDefault(command.Metadata)),
|
||
cancellationToken);
|
||
|
||
await AddAuditAsync(actor, "tenant.notification.upserted", "user_notifications", item.Id, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
return new ContentManagementResult<TenantAdminNotificationItem>(ToNotificationItem(item));
|
||
}
|
||
|
||
public async Task<CatalogList<TenantAdminFeedbackItem>> GetFeedbacksAsync(
|
||
TenantAdminActor actor,
|
||
TenantAdminFeedbackFilter filter,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var classIds = scope.ClassIds.ToArray();
|
||
var query = dbContext.Reports.AsNoTracking()
|
||
.Where(item => item.TenantId == actor.TenantId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
item => item.UserId == actor.UserId || item.HandledBy == actor.UserId,
|
||
item => item.UserId.HasValue &&
|
||
(dbContext.StudentProfiles.Any(profile =>
|
||
profile.TenantId == actor.TenantId &&
|
||
profile.UserId == item.UserId.Value &&
|
||
profile.RegionId.HasValue &&
|
||
regionIds.Contains(profile.RegionId.Value)) ||
|
||
dbContext.TenantClassMembers.Any(member =>
|
||
member.TenantId == actor.TenantId &&
|
||
member.UserId == item.UserId.Value &&
|
||
member.Status == TenantClassMemberStatus.Active &&
|
||
classIds.Contains(member.ClassId))));
|
||
if (filter.UserId.HasValue)
|
||
{
|
||
query = query.Where(item => item.UserId == filter.UserId.Value);
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(filter.Status))
|
||
{
|
||
query = query.Where(item => item.Status == ParseEnum<ReportStatus>(filter.Status, "invalid_feedback_status"));
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(filter.Type))
|
||
{
|
||
query = query.Where(item => item.Type == ParseEnum<ReportType>(filter.Type, "invalid_feedback_type"));
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||
{
|
||
var keyword = filter.Keyword.Trim();
|
||
query = query.Where(item =>
|
||
(item.Title != null && item.Title.Contains(keyword)) ||
|
||
(item.Description != null && item.Description.Contains(keyword)) ||
|
||
(item.Contact != null && item.Contact.Contains(keyword)));
|
||
}
|
||
|
||
var reports = await query
|
||
.OrderByDescending(item => item.CreatedAt)
|
||
.Take(ResolveLimit(filter.Limit))
|
||
.ToArrayAsync(cancellationToken);
|
||
var userIds = reports.Select(item => item.UserId).OfType<Guid>().Distinct().ToArray();
|
||
var users = await dbContext.Users.AsNoTracking()
|
||
.Where(user => userIds.Contains(user.Id))
|
||
.ToDictionaryAsync(user => user.Id, cancellationToken);
|
||
return new CatalogList<TenantAdminFeedbackItem>(reports.Select(report =>
|
||
ToFeedbackItem(report, report.UserId.HasValue && users.TryGetValue(report.UserId.Value, out var user) ? user : null)).ToArray());
|
||
}
|
||
|
||
public async Task<ContentManagementResult<TenantAdminFeedbackItem>> UpdateFeedbackAsync(
|
||
TenantAdminActor actor,
|
||
UpdateTenantAdminFeedbackCommand command,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var classIds = scope.ClassIds.ToArray();
|
||
var report = await dbContext.Reports
|
||
.Where(item => item.TenantId == actor.TenantId && item.Id == command.FeedbackId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
item => item.UserId == actor.UserId || item.HandledBy == actor.UserId,
|
||
item => item.UserId.HasValue &&
|
||
(dbContext.StudentProfiles.Any(profile =>
|
||
profile.TenantId == actor.TenantId &&
|
||
profile.UserId == item.UserId.Value &&
|
||
profile.RegionId.HasValue &&
|
||
regionIds.Contains(profile.RegionId.Value)) ||
|
||
dbContext.TenantClassMembers.Any(member =>
|
||
member.TenantId == actor.TenantId &&
|
||
member.UserId == item.UserId.Value &&
|
||
member.Status == TenantClassMemberStatus.Active &&
|
||
classIds.Contains(member.ClassId))))
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
if (report is null)
|
||
{
|
||
throw new TenantAdminDirectException("Feedback was not found.", "feedback_not_found");
|
||
}
|
||
|
||
var fromStatus = report.Status;
|
||
report.Status = ParseEnum(command.Status, report.Status, "invalid_feedback_status");
|
||
report.Priority = ParseEnum(command.Priority, report.Priority, "invalid_feedback_priority");
|
||
report.Resolution = Normalize(command.Resolution) ?? report.Resolution;
|
||
if (report.Status is ReportStatus.Accepted or ReportStatus.Rejected or ReportStatus.Resolved or ReportStatus.Closed)
|
||
{
|
||
report.HandledBy = actor.UserId;
|
||
report.HandledAt = DateTimeOffset.UtcNow;
|
||
}
|
||
|
||
dbContext.ReportStatusEvents.Add(new ReportStatusEvent
|
||
{
|
||
TenantId = actor.TenantId,
|
||
ReportId = report.Id,
|
||
FromStatus = fromStatus,
|
||
ToStatus = report.Status,
|
||
Note = Normalize(command.Note),
|
||
ActorUserId = actor.UserId,
|
||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||
});
|
||
await AddAuditAsync(actor, "tenant.feedback.updated", "reports", report.Id, cancellationToken);
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
var user = report.UserId.HasValue
|
||
? await dbContext.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == report.UserId.Value, cancellationToken)
|
||
: null;
|
||
return new ContentManagementResult<TenantAdminFeedbackItem>(ToFeedbackItem(report, user));
|
||
}
|
||
|
||
private async Task<User> 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<TenantMembership> 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)
|
||
{
|
||
var metricCode = QuotaMetricForRole(role);
|
||
if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken))
|
||
{
|
||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||
tenantId,
|
||
metricCode,
|
||
cancellationToken: cancellationToken);
|
||
}
|
||
membership = new TenantMembership
|
||
{
|
||
TenantId = tenantId,
|
||
UserId = userId,
|
||
Role = role,
|
||
Status = MembershipStatus.Active
|
||
};
|
||
dbContext.TenantMemberships.Add(membership);
|
||
}
|
||
else
|
||
{
|
||
if (membership.Status != MembershipStatus.Active)
|
||
{
|
||
var metricCode = QuotaMetricForRole(role);
|
||
if (!await IsUserCountedForMetricAsync(tenantId, userId, metricCode, cancellationToken))
|
||
{
|
||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||
tenantId,
|
||
metricCode,
|
||
cancellationToken: cancellationToken);
|
||
}
|
||
}
|
||
membership.Status = MembershipStatus.Active;
|
||
}
|
||
|
||
return membership;
|
||
}
|
||
|
||
private static string QuotaMetricForRole(TenantRole role) => role == TenantRole.Student
|
||
? SaasQuotaMetricCatalog.StudentCount
|
||
: SaasQuotaMetricCatalog.StaffCount;
|
||
|
||
private Task<bool> IsUserCountedForMetricAsync(
|
||
Guid tenantId,
|
||
Guid userId,
|
||
string metricCode,
|
||
CancellationToken cancellationToken,
|
||
Guid? excludedMembershipId = null)
|
||
{
|
||
var query = dbContext.TenantMemberships.AsNoTracking().Where(item =>
|
||
item.TenantId == tenantId &&
|
||
item.UserId == userId &&
|
||
item.Status == MembershipStatus.Active);
|
||
if (excludedMembershipId.HasValue)
|
||
{
|
||
query = query.Where(item => item.Id != excludedMembershipId.Value);
|
||
}
|
||
|
||
return metricCode == SaasQuotaMetricCatalog.StudentCount
|
||
? query.AnyAsync(item => item.Role == TenantRole.Student, cancellationToken)
|
||
: query.AnyAsync(item => item.Role != TenantRole.Student, cancellationToken);
|
||
}
|
||
|
||
private async Task<StudentProfile> 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<IReadOnlyCollection<TenantAdminStudentImportPreviewItem>> BuildStudentImportPreviewAsync(
|
||
TenantAdminActor actor,
|
||
CurrentDataScope scope,
|
||
TenantAdminStudentImportCommand command,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var items = new List<TenantAdminStudentImportPreviewItem>();
|
||
var rowNo = 0;
|
||
foreach (var row in command.Rows.Take(1000))
|
||
{
|
||
rowNo++;
|
||
string? reason = null;
|
||
var phone = Normalize(row.User.Phone);
|
||
var email = Normalize(row.User.Email);
|
||
var name = Normalize(row.User.Name);
|
||
if (row.User.UserId is null && phone is null && email is null && name is null)
|
||
{
|
||
reason = "user_required";
|
||
}
|
||
else if (!scope.AllowsResource(actor.UserId, actor.UserId, row.RegionId))
|
||
{
|
||
reason = "data_scope_denied";
|
||
}
|
||
else if (row.RegionId.HasValue && !await dbContext.Regions.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken))
|
||
{
|
||
reason = "region_not_found";
|
||
}
|
||
else if (row.ClassId.HasValue)
|
||
{
|
||
try
|
||
{
|
||
await AssertClassAsync(actor, scope, row.ClassId, cancellationToken);
|
||
}
|
||
catch (TenantAdminDirectException exception)
|
||
{
|
||
reason = exception.Code;
|
||
}
|
||
}
|
||
|
||
items.Add(new TenantAdminStudentImportPreviewItem(
|
||
rowNo,
|
||
reason is null,
|
||
reason,
|
||
phone,
|
||
email,
|
||
name,
|
||
row.RegionId,
|
||
row.ClassId));
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
private async Task<TenantClassMember> UpsertClassMemberCoreAsync(
|
||
TenantAdminActor actor,
|
||
Guid classId,
|
||
Guid userId,
|
||
TenantClassMemberType memberType,
|
||
TenantClassMemberStatus status,
|
||
JsonElement metadata,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member =>
|
||
member.TenantId == actor.TenantId &&
|
||
member.ClassId == classId &&
|
||
member.UserId == userId &&
|
||
member.MemberType == memberType,
|
||
cancellationToken);
|
||
if (item is null)
|
||
{
|
||
item = new TenantClassMember
|
||
{
|
||
TenantId = actor.TenantId,
|
||
ClassId = classId,
|
||
UserId = userId,
|
||
MemberType = memberType,
|
||
JoinedAt = DateTimeOffset.UtcNow
|
||
};
|
||
dbContext.TenantClassMembers.Add(item);
|
||
}
|
||
|
||
item.Status = status;
|
||
item.LeftAt = status == TenantClassMemberStatus.Removed ? DateTimeOffset.UtcNow : null;
|
||
item.Metadata = JsonObjectOrDefault(metadata);
|
||
return item;
|
||
}
|
||
|
||
private async Task<IReadOnlyCollection<TenantSupervisionRuleItem>> GetSupervisionRulesCoreAsync(
|
||
Guid tenantId,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var settings = await dbContext.TenantSettings.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||
if (settings is null ||
|
||
settings.AdminFeatureFlags.ValueKind != JsonValueKind.Object ||
|
||
!settings.AdminFeatureFlags.TryGetProperty("supervisionRules", out var rulesElement) ||
|
||
rulesElement.ValueKind != JsonValueKind.Array)
|
||
{
|
||
return [];
|
||
}
|
||
|
||
return JsonSerializer.Deserialize<TenantSupervisionRuleItem[]>(rulesElement.GetRawText()) ?? [];
|
||
}
|
||
|
||
private async Task SaveSupervisionRulesCoreAsync(
|
||
Guid tenantId,
|
||
IReadOnlyCollection<TenantSupervisionRuleItem> rules,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var settings = await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||
if (settings is null)
|
||
{
|
||
settings = new TenantSettings { TenantId = tenantId };
|
||
dbContext.TenantSettings.Add(settings);
|
||
}
|
||
|
||
var existing = settings.AdminFeatureFlags.ValueKind == JsonValueKind.Object
|
||
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(settings.AdminFeatureFlags.GetRawText()) ?? []
|
||
: [];
|
||
existing["supervisionRules"] = JsonSerializer.SerializeToElement(rules);
|
||
settings.AdminFeatureFlags = JsonSerializer.SerializeToElement(existing);
|
||
}
|
||
|
||
private async Task<IReadOnlyCollection<TenantSupervisionRiskStudentItem>> BuildSupervisionRiskStudentsAsync(
|
||
TenantAdminActor actor,
|
||
CurrentDataScope scope,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken))
|
||
.Where(rule => rule.Enabled)
|
||
.ToArray();
|
||
if (rules.Length == 0)
|
||
{
|
||
return [];
|
||
}
|
||
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var students = await dbContext.StudentProfiles.AsNoTracking()
|
||
.Where(profile => profile.TenantId == actor.TenantId)
|
||
.ApplyDataScope(
|
||
scope,
|
||
profile => profile.UserId == actor.UserId,
|
||
profile => profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value))
|
||
.Select(profile => new
|
||
{
|
||
Profile = profile,
|
||
User = dbContext.Users.Where(user => user.Id == profile.UserId).FirstOrDefault()
|
||
})
|
||
.ToArrayAsync(cancellationToken);
|
||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||
var result = new List<TenantSupervisionRiskStudentItem>();
|
||
foreach (var row in students)
|
||
{
|
||
var hitRules = new List<string>();
|
||
var reasons = new List<string>();
|
||
foreach (var rule in rules)
|
||
{
|
||
if (rule.DaysWithoutCheckIn.HasValue)
|
||
{
|
||
var days = row.Profile.LastCheckInDate.HasValue
|
||
? today.DayNumber - row.Profile.LastCheckInDate.Value.DayNumber
|
||
: int.MaxValue;
|
||
if (days >= rule.DaysWithoutCheckIn.Value)
|
||
{
|
||
hitRules.Add(rule.Code);
|
||
reasons.Add($"{rule.Title}: {days} days without check-in");
|
||
}
|
||
}
|
||
|
||
if (rule.MaxQuestionsAnsweredToday.HasValue &&
|
||
row.Profile.QuestionsAnsweredToday <= rule.MaxQuestionsAnsweredToday.Value)
|
||
{
|
||
hitRules.Add(rule.Code);
|
||
reasons.Add($"{rule.Title}: questions answered today <= {rule.MaxQuestionsAnsweredToday.Value}");
|
||
}
|
||
}
|
||
|
||
if (hitRules.Count > 0)
|
||
{
|
||
result.Add(new TenantSupervisionRiskStudentItem(
|
||
row.Profile.UserId,
|
||
row.User?.Name,
|
||
MaskPhone(row.User?.Phone),
|
||
row.Profile.RegionId,
|
||
hitRules.Distinct(StringComparer.Ordinal).ToArray(),
|
||
reasons.Distinct(StringComparer.Ordinal).ToArray()));
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
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 AssertStudentAsync(
|
||
TenantAdminActor actor,
|
||
CurrentDataScope scope,
|
||
Guid userId,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var classIds = scope.ClassIds.ToArray();
|
||
var exists = await dbContext.TenantMemberships
|
||
.Where(item => item.TenantId == actor.TenantId && item.UserId == 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)))
|
||
.AnyAsync(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 RevokeSessionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken)
|
||
{
|
||
await sessionStore.RevokeRealmAsync(
|
||
userId, AuthRealm.Tenant, tenantId, "membership_disabled", cancellationToken);
|
||
}
|
||
|
||
private async Task EnsureTenantOwnerBackendRoleAsync(
|
||
Guid tenantId,
|
||
Guid userId,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
const string roleCode = "tenant_owner";
|
||
var role = await dbContext.TenantBackendRoles.FirstOrDefaultAsync(
|
||
item => item.TenantId == tenantId && item.Code == roleCode,
|
||
cancellationToken);
|
||
if (role is null)
|
||
{
|
||
role = new TenantBackendRole
|
||
{
|
||
TenantId = tenantId,
|
||
Code = roleCode,
|
||
Name = "租户所有者",
|
||
Status = BackendRoleStatus.Active,
|
||
IsSystem = true,
|
||
Description = "系统内置租户所有者角色",
|
||
DataScope = JsonSerializer.SerializeToElement(new { mode = "All" })
|
||
};
|
||
dbContext.TenantBackendRoles.Add(role);
|
||
}
|
||
else
|
||
{
|
||
role.Status = BackendRoleStatus.Active;
|
||
role.IsSystem = true;
|
||
role.DataScope = JsonSerializer.SerializeToElement(new { mode = "All" });
|
||
}
|
||
|
||
var tenantPermissionCodes = BackendPermissions.Tenant.ToArray();
|
||
var existingPermissionCodes = await dbContext.BackendPermissions
|
||
.Where(permission => tenantPermissionCodes.Contains(permission.Code))
|
||
.Select(permission => permission.Code)
|
||
.ToArrayAsync(cancellationToken);
|
||
foreach (var permissionCode in BackendPermissions.Tenant.Except(existingPermissionCodes, StringComparer.Ordinal))
|
||
{
|
||
dbContext.BackendPermissions.Add(new BackendPermission
|
||
{
|
||
Code = permissionCode,
|
||
Name = permissionCode,
|
||
Area = BackendPermissionArea.Tenant,
|
||
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode),
|
||
IsSystem = true
|
||
});
|
||
}
|
||
|
||
var boundPermissionCodes = await dbContext.TenantBackendRolePermissions
|
||
.Where(binding => binding.TenantId == tenantId && binding.RoleId == role.Id)
|
||
.Select(binding => binding.PermissionCode)
|
||
.ToArrayAsync(cancellationToken);
|
||
dbContext.TenantBackendRolePermissions.AddRange(
|
||
tenantPermissionCodes
|
||
.Except(boundPermissionCodes, StringComparer.Ordinal)
|
||
.Select(permissionCode => new TenantBackendRolePermission
|
||
{
|
||
TenantId = tenantId,
|
||
RoleId = role.Id,
|
||
PermissionCode = permissionCode
|
||
}));
|
||
|
||
if (!await dbContext.TenantBackendUserRoles.AnyAsync(
|
||
binding => binding.TenantId == tenantId && binding.UserId == userId && binding.RoleId == role.Id,
|
||
cancellationToken))
|
||
{
|
||
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
|
||
{
|
||
TenantId = tenantId,
|
||
UserId = userId,
|
||
RoleId = role.Id
|
||
});
|
||
}
|
||
}
|
||
|
||
private async Task EnsureBrandingThemeAsync(
|
||
Guid tenantId,
|
||
JsonElement theme,
|
||
JsonElement publicAssets,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var branding = await dbContext.TenantBrandings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||
if (branding is null)
|
||
{
|
||
var tenantName = await dbContext.Tenants
|
||
.Where(tenant => tenant.Id == tenantId)
|
||
.Select(tenant => tenant.Name)
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
branding = new TenantBranding
|
||
{
|
||
TenantId = tenantId,
|
||
BrandName = tenantName ?? "租户题库"
|
||
};
|
||
dbContext.TenantBrandings.Add(branding);
|
||
}
|
||
|
||
branding.Theme = theme.Clone();
|
||
branding.PublicAssets = MergeJsonObjects(branding.PublicAssets, publicAssets);
|
||
}
|
||
|
||
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 AssertClassAsync(
|
||
TenantAdminActor actor,
|
||
CurrentDataScope scope,
|
||
Guid? classId,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (!classId.HasValue)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var regionIds = scope.RegionIds.ToArray();
|
||
var classIds = scope.ClassIds.ToArray();
|
||
var exists = await dbContext.TenantClasses
|
||
.Where(item => item.TenantId == actor.TenantId && item.Id == classId.Value)
|
||
.ApplyDataScope(
|
||
scope,
|
||
item => item.CreatedBy == actor.UserId,
|
||
item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)))
|
||
.AnyAsync(cancellationToken);
|
||
if (!exists)
|
||
{
|
||
throw new TenantAdminDirectException("Class was not found.", "class_not_found");
|
||
}
|
||
}
|
||
|
||
private async Task<CurrentDataScope> RequireDataScopeAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||
if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId)
|
||
{
|
||
throw new TenantAdminDirectException("Tenant member was not found.", "tenant_member_not_found");
|
||
}
|
||
|
||
return access.DataScope;
|
||
}
|
||
|
||
private async Task RequireAllDataScopeAsync(
|
||
TenantAdminActor actor,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||
if (scope.Mode != DataScopeMode.All)
|
||
{
|
||
throw new TenantAdminDirectException("Tenant-wide resource was not found.", "tenant_resource_not_found");
|
||
}
|
||
}
|
||
|
||
private async Task AssertReferenceAsync<TEntity>(
|
||
Guid tenantId,
|
||
Guid? id,
|
||
string code,
|
||
CancellationToken cancellationToken)
|
||
where TEntity : TenantEntity
|
||
{
|
||
if (!id.HasValue)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var exists = await dbContext.Set<TEntity>().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<TEntity?> ResolveTenantEntityAsync<TEntity>(
|
||
DbSet<TEntity> 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<string?>(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<Guid, string> regions,
|
||
IReadOnlyDictionary<Guid, string> schools,
|
||
IReadOnlyDictionary<Guid, string> majors,
|
||
IReadOnlyCollection<TenantAdminStudentClassSummary> 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 TenantAdminMemberItem ToMemberItem(TenantMembership membership, User user)
|
||
{
|
||
return new TenantAdminMemberItem(
|
||
membership.Id,
|
||
membership.UserId,
|
||
membership.Role,
|
||
membership.Status,
|
||
membership.LegacyRole,
|
||
ToUserSummary(user),
|
||
membership.CreatedAt,
|
||
membership.UpdatedAt);
|
||
}
|
||
|
||
private static TenantBrandingItem ToBrandingItem(TenantBranding item)
|
||
{
|
||
return new TenantBrandingItem(
|
||
item.TenantId,
|
||
item.BrandName,
|
||
item.ShortName,
|
||
item.Slogan,
|
||
item.OrganizationName,
|
||
item.LogoUrl,
|
||
item.FaviconUrl,
|
||
item.ServiceWechat,
|
||
item.ServiceAccountName,
|
||
item.Theme,
|
||
item.PublicAssets,
|
||
item.UpdatedAt);
|
||
}
|
||
|
||
private static TenantSettingsItem ToSettingsItem(TenantSettings item)
|
||
{
|
||
return new TenantSettingsItem(
|
||
item.TenantId,
|
||
item.FeatureFlags,
|
||
item.AdminFeatureFlags,
|
||
item.PublicConfig,
|
||
item.UpdatedAt);
|
||
}
|
||
|
||
private static TenantThemeItem ToThemeItem(TenantThemeConfig item)
|
||
{
|
||
return new TenantThemeItem(
|
||
item.TenantId,
|
||
item.ActiveTemplateCode,
|
||
item.ActiveTheme,
|
||
item.ActivePublicAssets,
|
||
item.DraftTemplateCode,
|
||
item.DraftTheme,
|
||
item.DraftPublicAssets,
|
||
item.Status,
|
||
item.PublishedAt,
|
||
item.PublishedBy,
|
||
item.DraftUpdatedBy,
|
||
item.UpdatedAt);
|
||
}
|
||
|
||
private static TenantDomainItem ToDomainItem(TenantDomain item)
|
||
{
|
||
return new TenantDomainItem(
|
||
item.Id,
|
||
item.Host,
|
||
item.DomainType,
|
||
item.Status,
|
||
item.IsPrimary,
|
||
item.VerificationToken,
|
||
item.VerifiedAt,
|
||
item.LastCheckedAt,
|
||
item.DnsVerifiedAt,
|
||
item.TlsReadyAt,
|
||
item.LastFailureReason,
|
||
item.CreatedAt,
|
||
item.UpdatedAt);
|
||
}
|
||
|
||
private static TenantIdentityProviderItem ToAuthProviderItem(TenantExternalProviderItem item)
|
||
{
|
||
return new TenantIdentityProviderItem(
|
||
item.Id,
|
||
item.Provider,
|
||
item.Status,
|
||
item.DisplayName,
|
||
item.SecretRef,
|
||
item.Priority,
|
||
item.ConfigPublic,
|
||
item.CreatedAt,
|
||
item.UpdatedAt);
|
||
}
|
||
|
||
private static TenantAdminBadgeItem ToBadgeItem(Badge item)
|
||
{
|
||
return new TenantAdminBadgeItem(
|
||
item.Id,
|
||
item.LegacyId,
|
||
item.Name,
|
||
item.Description,
|
||
item.Category,
|
||
item.IconUrl,
|
||
item.Level,
|
||
item.UnlockType,
|
||
item.ConditionField,
|
||
item.ConditionOperator,
|
||
item.ConditionValue,
|
||
item.ConditionExtra,
|
||
item.SortOrder,
|
||
item.IsActive,
|
||
item.CreatedAt,
|
||
item.UpdatedAt);
|
||
}
|
||
|
||
private static TenantAdminBadgeGrantItem ToBadgeGrantItem(UserBadge grant, User? user, User? grantedBy, Badge? badge)
|
||
{
|
||
return new TenantAdminBadgeGrantItem(
|
||
grant.Id,
|
||
grant.LegacyId,
|
||
grant.UserId,
|
||
user?.Name ?? user?.UserName,
|
||
user?.Phone,
|
||
grant.BadgeId,
|
||
badge?.Name,
|
||
badge?.Category,
|
||
badge?.IconUrl,
|
||
badge?.Level,
|
||
grant.GrantedBy,
|
||
grantedBy?.Name ?? grantedBy?.UserName,
|
||
grant.Note,
|
||
grant.GrantedAt,
|
||
grant.CreatedAt,
|
||
grant.UpdatedAt);
|
||
}
|
||
|
||
private static TenantAdminNotificationItem ToNotificationItem(UserNotification item)
|
||
{
|
||
return new TenantAdminNotificationItem(
|
||
item.Id,
|
||
item.UserId,
|
||
item.NotificationType,
|
||
item.Status,
|
||
item.Severity,
|
||
item.Title,
|
||
item.Message,
|
||
item.ActionLabel,
|
||
item.ActionPath,
|
||
item.SourceType,
|
||
item.SourceId,
|
||
item.DedupeKey,
|
||
item.Metadata,
|
||
item.CreatedBy,
|
||
item.ReadAt,
|
||
item.CreatedAt,
|
||
item.UpdatedAt);
|
||
}
|
||
|
||
private static TenantAdminFeedbackItem ToFeedbackItem(Report report, User? user)
|
||
{
|
||
return new TenantAdminFeedbackItem(
|
||
report.Id,
|
||
report.UserId,
|
||
user?.Name ?? user?.UserName,
|
||
user?.Phone,
|
||
report.QuestionId,
|
||
report.Type,
|
||
report.Title,
|
||
report.Category,
|
||
report.Description,
|
||
report.Status,
|
||
report.Priority,
|
||
report.HandledBy,
|
||
report.HandledAt,
|
||
report.Resolution,
|
||
report.Contact,
|
||
report.Attachments,
|
||
report.Metadata,
|
||
report.CreatedAt,
|
||
report.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 string NormalizeCode(string value)
|
||
{
|
||
return value.Trim().ToLowerInvariant();
|
||
}
|
||
|
||
private static string? MaskPhone(string? phone)
|
||
{
|
||
var value = Normalize(phone);
|
||
return value is { Length: >= 7 }
|
||
? $"{value[..3]}****{value[^4..]}"
|
||
: value;
|
||
}
|
||
|
||
private static JsonElement JsonObjectOrDefault(JsonElement value)
|
||
{
|
||
return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();
|
||
}
|
||
|
||
private static JsonElement PermissionObject(JsonElement value)
|
||
{
|
||
var result = new Dictionary<string, bool>(StringComparer.Ordinal);
|
||
if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||
{
|
||
return JsonDefaults.Object();
|
||
}
|
||
|
||
if (value.ValueKind != JsonValueKind.Object)
|
||
{
|
||
throw new TenantAdminDirectException("Permissions must be an object.", "invalid_permission_value");
|
||
}
|
||
|
||
foreach (var property in value.EnumerateObject())
|
||
{
|
||
if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False)
|
||
{
|
||
throw new TenantAdminDirectException("Permission values must be boolean.", "invalid_permission_value");
|
||
}
|
||
|
||
if (property.Name != "*" && !IsPermissionKey(property.Name))
|
||
{
|
||
throw new TenantAdminDirectException("Permission key was invalid.", "invalid_permission_key");
|
||
}
|
||
|
||
result[property.Name] = property.Value.GetBoolean();
|
||
}
|
||
|
||
return JsonSerializer.SerializeToElement(result);
|
||
}
|
||
|
||
private static JsonElement AccessMap(JsonElement value, string codePrefix)
|
||
{
|
||
var result = new Dictionary<string, bool>(StringComparer.Ordinal);
|
||
if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||
{
|
||
return JsonDefaults.Object();
|
||
}
|
||
|
||
if (value.ValueKind != JsonValueKind.Object)
|
||
{
|
||
throw new TenantAdminDirectException("Access map must be an object.", $"invalid_{codePrefix}");
|
||
}
|
||
|
||
foreach (var property in value.EnumerateObject())
|
||
{
|
||
if (property.Value.ValueKind != JsonValueKind.True && property.Value.ValueKind != JsonValueKind.False)
|
||
{
|
||
throw new TenantAdminDirectException("Access map values must be boolean.", $"invalid_{codePrefix}");
|
||
}
|
||
|
||
if (!IsAccessKey(property.Name))
|
||
{
|
||
throw new TenantAdminDirectException("Access map key was invalid.", $"invalid_{codePrefix}_key");
|
||
}
|
||
|
||
result[property.Name] = property.Value.GetBoolean();
|
||
}
|
||
|
||
return JsonSerializer.SerializeToElement(result);
|
||
}
|
||
|
||
private static JsonElement DataScope(JsonElement value)
|
||
{
|
||
if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||
{
|
||
return JsonDefaults.Object();
|
||
}
|
||
|
||
if (value.ValueKind != JsonValueKind.Object)
|
||
{
|
||
throw new TenantAdminDirectException("Data scope must be an object.", "invalid_data_scope");
|
||
}
|
||
|
||
var allowed = new HashSet<string>(StringComparer.Ordinal)
|
||
{
|
||
"mode",
|
||
"regionIds",
|
||
"contentNodeIds",
|
||
"classIds",
|
||
"ownLeadsOnly",
|
||
"teamScope",
|
||
"metadata"
|
||
};
|
||
foreach (var property in value.EnumerateObject())
|
||
{
|
||
if (!allowed.Contains(property.Name))
|
||
{
|
||
throw new TenantAdminDirectException("Data scope key was invalid.", "invalid_data_scope_key");
|
||
}
|
||
}
|
||
|
||
return value.Clone();
|
||
}
|
||
|
||
private async Task AssertGrantableAsync(
|
||
TenantAdminActor actor,
|
||
TenantRole role,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (role is not (TenantRole.TenantOwner or TenantRole.TenantAdmin))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var isOwnerRoleHolder = await (
|
||
from binding in dbContext.TenantBackendUserRoles.AsNoTracking()
|
||
join backendRole in dbContext.TenantBackendRoles.AsNoTracking()
|
||
on new { binding.TenantId, binding.RoleId } equals new { backendRole.TenantId, RoleId = backendRole.Id }
|
||
where binding.TenantId == actor.TenantId &&
|
||
binding.UserId == actor.UserId &&
|
||
backendRole.Code == "tenant_owner" &&
|
||
backendRole.IsSystem &&
|
||
backendRole.Status == BackendRoleStatus.Active
|
||
select binding.Id)
|
||
.AnyAsync(cancellationToken);
|
||
if (!isOwnerRoleHolder)
|
||
{
|
||
throw new TenantAdminDirectException("Only tenant owner can grant owner/admin permissions.", "tenant_owner_required");
|
||
}
|
||
}
|
||
|
||
private static string RoleToPrimaryRole(TenantRole role)
|
||
{
|
||
return role switch
|
||
{
|
||
TenantRole.Student => "student",
|
||
TenantRole.Teacher => "teacher",
|
||
TenantRole.Sales => "sales",
|
||
TenantRole.Agent => "agent",
|
||
TenantRole.TenantOperator => "tenant_operator",
|
||
TenantRole.TenantAdmin => "tenant_admin",
|
||
TenantRole.TenantOwner => "tenant_owner",
|
||
_ => "student"
|
||
};
|
||
}
|
||
|
||
private static string NormalizeRoleCode(string value)
|
||
{
|
||
var code = new string(value.Trim().ToLowerInvariant()
|
||
.Select(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' ? character : '-')
|
||
.ToArray())
|
||
.Trim('-');
|
||
while (code.Contains("--", StringComparison.Ordinal))
|
||
{
|
||
code = code.Replace("--", "-", StringComparison.Ordinal);
|
||
}
|
||
|
||
if (code.Length is < 2 or > 64 || !char.IsAsciiLetter(code[0]))
|
||
{
|
||
throw new TenantAdminDirectException("Role template code was invalid.", "invalid_role_template_code");
|
||
}
|
||
|
||
return code;
|
||
}
|
||
|
||
private static string NormalizeDomain(string value)
|
||
{
|
||
var host = Normalize(value)?.ToLowerInvariant()
|
||
.TrimEnd('.') ?? throw new TenantAdminDirectException("Domain host is required.", "domain_host_required");
|
||
if (host.Length > 253 || host.Contains('/', StringComparison.Ordinal) || host.Contains(':', StringComparison.Ordinal) || !host.Contains('.', StringComparison.Ordinal))
|
||
{
|
||
throw new TenantAdminDirectException("Domain host was invalid.", "invalid_domain_host");
|
||
}
|
||
|
||
return host;
|
||
}
|
||
|
||
private static JsonElement MergeJsonObjects(JsonElement first, JsonElement second)
|
||
{
|
||
var result = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||
if (first.ValueKind == JsonValueKind.Object)
|
||
{
|
||
foreach (var property in first.EnumerateObject())
|
||
{
|
||
result[property.Name] = property.Value.Clone();
|
||
}
|
||
}
|
||
|
||
if (second.ValueKind == JsonValueKind.Object)
|
||
{
|
||
foreach (var property in second.EnumerateObject())
|
||
{
|
||
result[property.Name] = property.Value.Clone();
|
||
}
|
||
}
|
||
|
||
return JsonSerializer.SerializeToElement(result);
|
||
}
|
||
|
||
private static void AssertNoSecrets(JsonElement value, string code)
|
||
{
|
||
if (value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (value.ValueKind != JsonValueKind.Object)
|
||
{
|
||
throw new TenantAdminDirectException("Public config must be an object.", $"invalid_{code}");
|
||
}
|
||
|
||
foreach (var property in value.EnumerateObject())
|
||
{
|
||
var key = property.Name.ToLowerInvariant();
|
||
if (key.Contains("secret", StringComparison.Ordinal) ||
|
||
key.Contains("password", StringComparison.Ordinal) ||
|
||
key.Contains("token", StringComparison.Ordinal) ||
|
||
key.Contains("private", StringComparison.Ordinal) ||
|
||
key is "appsecret" or "app_secret" or "accesskeysecret")
|
||
{
|
||
throw new TenantAdminDirectException("Public config cannot contain secrets.", "public_config_contains_secret");
|
||
}
|
||
}
|
||
}
|
||
|
||
private static bool IsPermissionKey(string key)
|
||
{
|
||
return key.Length <= 100 &&
|
||
key.Contains(':', StringComparison.Ordinal) &&
|
||
key.All(character => char.IsAsciiLetterOrDigit(character) || character is ':' or '*');
|
||
}
|
||
|
||
private static bool IsAccessKey(string key)
|
||
{
|
||
return key.Length <= 100 &&
|
||
key.Length > 0 &&
|
||
char.IsAsciiLetter(key[0]) &&
|
||
key.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '.' or ':' or '-');
|
||
}
|
||
|
||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback, string code)
|
||
where TEnum : struct, Enum
|
||
{
|
||
return string.IsNullOrWhiteSpace(value) ? fallback : ParseEnum<TEnum>(value, code);
|
||
}
|
||
|
||
private static TEnum ParseEnum<TEnum>(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<TEnum>())
|
||
{
|
||
if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
throw new TenantAdminDirectException("Enum value was invalid.", code);
|
||
}
|
||
}
|