605 lines
25 KiB
C#
605 lines
25 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Application.TenantAdmin;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Security;
|
|
|
|
namespace Tiku.Infrastructure.TenantAdmin;
|
|
|
|
internal abstract partial class TenantAdminServiceBase
|
|
{
|
|
protected async Task<User> ResolveUserAsync(UserLookupCommand command, string primaryRole,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
User? user = null;
|
|
if (command.UserId.HasValue)
|
|
{
|
|
user = await identityPersistence.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 identityPersistence.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()
|
|
};
|
|
identityPersistence.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;
|
|
}
|
|
|
|
protected async Task<TenantMembership> EnsureMembershipAsync(
|
|
Guid tenantId,
|
|
Guid userId,
|
|
TenantRole role,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var membership = await identityPersistence.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
|
|
};
|
|
identityPersistence.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;
|
|
}
|
|
|
|
protected static string QuotaMetricForRole(TenantRole role)
|
|
{
|
|
return role == TenantRole.Student
|
|
? SaasQuotaMetricCatalog.StudentCount
|
|
: SaasQuotaMetricCatalog.StaffCount;
|
|
}
|
|
|
|
protected Task<bool> IsUserCountedForMetricAsync(
|
|
Guid tenantId,
|
|
Guid userId,
|
|
string metricCode,
|
|
CancellationToken cancellationToken,
|
|
Guid? excludedMembershipId = null)
|
|
{
|
|
var query = identityPersistence.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);
|
|
}
|
|
|
|
protected 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 tenantAdministrationPersistence.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()
|
|
};
|
|
tenantAdministrationPersistence.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;
|
|
}
|
|
|
|
protected 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 catalogPersistence.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;
|
|
}
|
|
|
|
protected async Task<TenantClassMember> UpsertClassMemberCoreAsync(
|
|
TenantAdminActor actor,
|
|
Guid classId,
|
|
Guid userId,
|
|
TenantClassMemberType memberType,
|
|
TenantClassMemberStatus status,
|
|
JsonElement metadata,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var item = await tenantAdministrationPersistence.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
|
|
};
|
|
tenantAdministrationPersistence.TenantClassMembers.Add(item);
|
|
}
|
|
|
|
item.Status = status;
|
|
item.LeftAt = status == TenantClassMemberStatus.Removed ? DateTimeOffset.UtcNow : null;
|
|
item.Metadata = JsonObjectOrDefault(metadata);
|
|
return item;
|
|
}
|
|
|
|
protected async Task<IReadOnlyCollection<TenantSupervisionRuleItem>> GetSupervisionRulesCoreAsync(
|
|
Guid tenantId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var settings = await tenancyPersistence.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()) ?? [];
|
|
}
|
|
|
|
protected async Task SaveSupervisionRulesCoreAsync(
|
|
Guid tenantId,
|
|
IReadOnlyCollection<TenantSupervisionRuleItem> rules,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var settings =
|
|
await tenancyPersistence.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
|
if (settings is null)
|
|
{
|
|
settings = new TenantSettings { TenantId = tenantId };
|
|
tenancyPersistence.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);
|
|
}
|
|
|
|
protected 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 tenantAdministrationPersistence.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 = identityPersistence.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;
|
|
}
|
|
|
|
protected async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken)
|
|
{
|
|
var exists = await identityPersistence.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");
|
|
}
|
|
|
|
protected async Task AssertStudentAsync(
|
|
TenantAdminActor actor,
|
|
CurrentDataScope scope,
|
|
Guid userId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var regionIds = scope.RegionIds.ToArray();
|
|
var classIds = scope.ClassIds.ToArray();
|
|
var exists = await identityPersistence.TenantMemberships
|
|
.Where(item => item.TenantId == actor.TenantId && item.UserId == userId && item.Role == TenantRole.Student)
|
|
.ApplyDataScope(
|
|
scope,
|
|
item => item.UserId == actor.UserId,
|
|
item => tenantAdministrationPersistence.StudentProfiles.Any(profile =>
|
|
profile.TenantId == actor.TenantId &&
|
|
profile.UserId == item.UserId &&
|
|
profile.RegionId.HasValue &&
|
|
regionIds.Contains(profile.RegionId.Value)) ||
|
|
tenantAdministrationPersistence.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");
|
|
}
|
|
|
|
protected async Task AssertTenantMemberAsync(Guid tenantId, Guid? userId, CancellationToken cancellationToken)
|
|
{
|
|
if (!userId.HasValue) return;
|
|
|
|
var exists = await identityPersistence.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");
|
|
}
|
|
|
|
protected async Task RevokeSessionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken)
|
|
{
|
|
await sessionStore.RevokeRealmAsync(
|
|
userId, AuthRealm.Tenant, tenantId, "membership_disabled", cancellationToken);
|
|
}
|
|
|
|
protected async Task EnsureTenantOwnerBackendRoleAsync(
|
|
Guid tenantId,
|
|
Guid userId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
const string roleCode = "tenant_owner";
|
|
var role = await jobsOperationsPersistence.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" })
|
|
};
|
|
jobsOperationsPersistence.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 jobsOperationsPersistence.BackendPermissions
|
|
.Where(permission => tenantPermissionCodes.Contains(permission.Code))
|
|
.Select(permission => permission.Code)
|
|
.ToArrayAsync(cancellationToken);
|
|
foreach (var permissionCode in
|
|
BackendPermissions.Tenant.Except(existingPermissionCodes, StringComparer.Ordinal))
|
|
jobsOperationsPersistence.BackendPermissions.Add(new BackendPermission
|
|
{
|
|
Code = permissionCode,
|
|
Name = permissionCode,
|
|
Area = BackendPermissionArea.Tenant,
|
|
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode),
|
|
IsSystem = true
|
|
});
|
|
|
|
var boundPermissionCodes = await jobsOperationsPersistence.TenantBackendRolePermissions
|
|
.Where(binding => binding.TenantId == tenantId && binding.RoleId == role.Id)
|
|
.Select(binding => binding.PermissionCode)
|
|
.ToArrayAsync(cancellationToken);
|
|
jobsOperationsPersistence.TenantBackendRolePermissions.AddRange(
|
|
tenantPermissionCodes
|
|
.Except(boundPermissionCodes, StringComparer.Ordinal)
|
|
.Select(permissionCode => new TenantBackendRolePermission
|
|
{
|
|
TenantId = tenantId,
|
|
RoleId = role.Id,
|
|
PermissionCode = permissionCode
|
|
}));
|
|
|
|
if (!await jobsOperationsPersistence.TenantBackendUserRoles.AnyAsync(
|
|
binding => binding.TenantId == tenantId && binding.UserId == userId && binding.RoleId == role.Id,
|
|
cancellationToken))
|
|
jobsOperationsPersistence.TenantBackendUserRoles.Add(new TenantBackendUserRole
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
RoleId = role.Id
|
|
});
|
|
}
|
|
|
|
protected async Task EnsureBrandingThemeAsync(
|
|
Guid tenantId,
|
|
JsonElement theme,
|
|
JsonElement publicAssets,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var branding =
|
|
await tenancyPersistence.TenantBrandings.FirstOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
|
if (branding is null)
|
|
{
|
|
var tenantName = await tenancyPersistence.Tenants
|
|
.Where(tenant => tenant.Id == tenantId)
|
|
.Select(tenant => tenant.Name)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
branding = new TenantBranding
|
|
{
|
|
TenantId = tenantId,
|
|
BrandName = tenantName ?? "租户题库"
|
|
};
|
|
tenancyPersistence.TenantBrandings.Add(branding);
|
|
}
|
|
|
|
branding.Theme = theme.Clone();
|
|
branding.PublicAssets = MergeJsonObjects(branding.PublicAssets, publicAssets);
|
|
}
|
|
|
|
protected async Task AssertClassAsync(Guid tenantId, Guid? classId, CancellationToken cancellationToken)
|
|
{
|
|
if (!classId.HasValue) return;
|
|
|
|
var exists = await tenantAdministrationPersistence.TenantClasses.AnyAsync(
|
|
item => item.TenantId == tenantId && item.Id == classId.Value,
|
|
cancellationToken);
|
|
if (!exists) throw new TenantAdminDirectException("Class was not found.", "class_not_found");
|
|
}
|
|
|
|
protected 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 tenantAdministrationPersistence.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");
|
|
}
|
|
|
|
protected 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;
|
|
}
|
|
|
|
protected 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");
|
|
}
|
|
|
|
protected async Task AssertReferenceAsync<TEntity>(
|
|
Guid tenantId,
|
|
Guid? id,
|
|
string code,
|
|
CancellationToken cancellationToken)
|
|
where TEntity : TenantEntity
|
|
{
|
|
if (!id.HasValue) return;
|
|
|
|
var exists = await unitOfWork.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);
|
|
}
|
|
|
|
protected 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);
|
|
}
|
|
|
|
protected async Task AddAuditAsync(
|
|
TenantAdminActor actor,
|
|
string action,
|
|
string targetType,
|
|
Guid targetId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
jobsOperationsPersistence.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);
|
|
}
|
|
}
|