feat: complete phase six backoffice operations
This commit is contained in:
931
Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs
Normal file
931
Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs
Normal file
@@ -0,0 +1,931 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformAdmin;
|
||||
|
||||
internal sealed class PlatformAdminService(
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
ITenantExecutionScope tenantExecutionScope) : IPlatformAdminService
|
||||
{
|
||||
public async Task<PlatformOverview> GetOverviewAsync(
|
||||
PlatformAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformDashboardView, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform overview", async dbContext =>
|
||||
{
|
||||
var tenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned, cancellationToken);
|
||||
var activeTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Active, cancellationToken);
|
||||
var suspendedTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Suspended, cancellationToken);
|
||||
var orderCount = await dbContext.Orders.CountAsync(cancellationToken);
|
||||
var paidOrderCount = await dbContext.Orders.CountAsync(order => order.Status == OrderStatus.Paid, cancellationToken);
|
||||
var revenueCents = await dbContext.Orders
|
||||
.Where(order => order.Status == OrderStatus.Paid || order.Status == OrderStatus.PartiallyRefunded)
|
||||
.SumAsync(order => order.AmountCents - order.RefundedAmountCents, cancellationToken);
|
||||
var questionBankCount = await dbContext.QuestionBanks.CountAsync(cancellationToken);
|
||||
var questionCount = await dbContext.Questions.CountAsync(question => question.Status == QuestionStatus.Published, cancellationToken);
|
||||
var learningActiveUserCount = await dbContext.PracticeSessions
|
||||
.Where(session => session.StartedAt >= DateTimeOffset.UtcNow.AddDays(-7))
|
||||
.Select(session => session.UserId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
return new PlatformOverview(
|
||||
tenantCount,
|
||||
activeTenantCount,
|
||||
suspendedTenantCount,
|
||||
orderCount,
|
||||
paidOrderCount,
|
||||
revenueCents,
|
||||
questionBankCount,
|
||||
questionCount,
|
||||
learningActiveUserCount);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantList> GetTenantsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant list", async dbContext =>
|
||||
{
|
||||
var tenants = dbContext.Tenants.AsNoTracking()
|
||||
.Where(tenant => tenant.Mode != TenantMode.PlatformOwned);
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
tenants = tenants.Where(tenant =>
|
||||
tenant.Slug.Contains(search) ||
|
||||
tenant.Name.Contains(search) ||
|
||||
(tenant.LegalName != null && tenant.LegalName.Contains(search)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
tenants = tenants.Where(tenant => tenant.Status == ParseTenantStatus(query.Status));
|
||||
}
|
||||
|
||||
var rows = await tenants
|
||||
.OrderByDescending(tenant => tenant.CreatedAt)
|
||||
.Take(Limit(query.Limit))
|
||||
.Select(tenant => new
|
||||
{
|
||||
Tenant = tenant,
|
||||
DomainCount = dbContext.TenantDomains.Count(domain => domain.TenantId == tenant.Id),
|
||||
SubscriptionExpiresAt = dbContext.TenantSubscriptions
|
||||
.Where(subscription => subscription.TenantId == tenant.Id)
|
||||
.OrderByDescending(subscription => subscription.ExpiresAt)
|
||||
.Select(subscription => subscription.ExpiresAt)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new PlatformTenantList(rows.Select(row => ToTenantItem(row.Tenant, row.DomainCount, row.SubscriptionExpiresAt)).ToArray());
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantDetail> GetTenantDetailAsync(
|
||||
PlatformAdminActor actor,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant detail", async dbContext =>
|
||||
{
|
||||
var tenant = await dbContext.Tenants.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == tenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
|
||||
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
|
||||
var domains = await dbContext.TenantDomains.AsNoTracking()
|
||||
.Where(domain => domain.TenantId == tenantId)
|
||||
.OrderByDescending(domain => domain.IsPrimary)
|
||||
.ThenBy(domain => domain.Host)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var subscriptions = await dbContext.TenantSubscriptions.AsNoTracking()
|
||||
.Where(subscription => subscription.TenantId == tenantId)
|
||||
.OrderByDescending(subscription => subscription.CreatedAt)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var billingProfile = await dbContext.TenantBillingProfiles.AsNoTracking()
|
||||
.SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken);
|
||||
|
||||
return new PlatformTenantDetail(
|
||||
ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.ExpiresAt),
|
||||
domains.Select(ToDomainItem).ToArray(),
|
||||
subscriptions.Select(ToSubscriptionItem).ToArray(),
|
||||
billingProfile is null ? null : ToBillingProfileItem(billingProfile));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantItem> CreateTenantAsync(
|
||||
PlatformAdminActor actor,
|
||||
CreatePlatformTenantCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant create", async dbContext =>
|
||||
{
|
||||
var slug = NormalizeCode(command.Slug);
|
||||
if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken))
|
||||
{
|
||||
throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists");
|
||||
}
|
||||
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Slug = slug,
|
||||
Name = command.Name.Trim(),
|
||||
LegalName = Normalize(command.LegalName),
|
||||
Status = command.Status,
|
||||
Mode = TenantMode.Saas,
|
||||
BillingStatus = command.BillingStatus,
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
dbContext.Tenants.Add(tenant);
|
||||
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToTenantItem(tenant, 0, null);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantItem> UpdateTenantStatusAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpdatePlatformTenantStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant status update", async dbContext =>
|
||||
{
|
||||
var tenant = await dbContext.Tenants
|
||||
.SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
|
||||
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
|
||||
var fromStatus = tenant.Status;
|
||||
var fromBilling = tenant.BillingStatus;
|
||||
tenant.Status = command.Status;
|
||||
tenant.BillingStatus = command.BillingStatus;
|
||||
AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new
|
||||
{
|
||||
FromStatus = fromStatus,
|
||||
ToStatus = tenant.Status,
|
||||
FromBillingStatus = fromBilling,
|
||||
ToBillingStatus = tenant.BillingStatus,
|
||||
command.Reason
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
|
||||
var expiresAt = await dbContext.TenantSubscriptions
|
||||
.Where(subscription => subscription.TenantId == tenant.Id)
|
||||
.OrderByDescending(subscription => subscription.ExpiresAt)
|
||||
.Select(subscription => subscription.ExpiresAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return ToTenantItem(tenant, domainCount, expiresAt);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpsertPlatformTenantBillingProfileCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant billing profile upsert", async dbContext =>
|
||||
{
|
||||
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
|
||||
var profile = await dbContext.TenantBillingProfiles.SingleOrDefaultAsync(
|
||||
item => item.TenantId == command.TenantId,
|
||||
cancellationToken);
|
||||
if (profile is null)
|
||||
{
|
||||
profile = new TenantBillingProfile { TenantId = command.TenantId };
|
||||
dbContext.TenantBillingProfiles.Add(profile);
|
||||
}
|
||||
|
||||
profile.BillingName = Normalize(command.BillingName);
|
||||
profile.TaxId = Normalize(command.TaxId);
|
||||
profile.ContactName = Normalize(command.ContactName);
|
||||
profile.ContactPhone = Normalize(command.ContactPhone);
|
||||
profile.ContactEmail = Normalize(command.ContactEmail);
|
||||
profile.BillingAddress = Normalize(command.BillingAddress);
|
||||
profile.InvoiceTitle = Normalize(command.InvoiceTitle);
|
||||
profile.InvoiceType = command.InvoiceType;
|
||||
profile.BankName = Normalize(command.BankName);
|
||||
profile.BankAccountMasked = MaskBankAccount(command.BankAccountMasked);
|
||||
profile.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
AddAudit(dbContext, actor, "platform.tenant.billing_profile.updated", command.TenantId, new { profile.BillingName, profile.InvoiceType });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToBillingProfileItem(profile);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformPlanList> GetPlansAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform plan list", async dbContext =>
|
||||
{
|
||||
var plans = dbContext.PlatformSaasPlans.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
plans = plans.Where(plan => plan.Status == ParsePlanStatus(query.Status));
|
||||
}
|
||||
|
||||
return new PlatformPlanList(await plans
|
||||
.OrderBy(plan => plan.SortOrder)
|
||||
.ThenBy(plan => plan.Code)
|
||||
.Take(Limit(query.Limit))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantSubscriptionItem> UpsertSubscriptionAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpsertPlatformSubscriptionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant subscription upsert", async dbContext =>
|
||||
{
|
||||
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
|
||||
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == NormalizeCode(command.PlanCode), cancellationToken))
|
||||
{
|
||||
throw new PlatformAdminException("SaaS plan was not found.", "plan_not_found");
|
||||
}
|
||||
|
||||
var subscription = await dbContext.TenantSubscriptions.SingleOrDefaultAsync(
|
||||
item => item.TenantId == command.TenantId && item.PlanCode == NormalizeCode(command.PlanCode),
|
||||
cancellationToken);
|
||||
if (subscription is null)
|
||||
{
|
||||
subscription = new TenantSubscription { TenantId = command.TenantId };
|
||||
dbContext.TenantSubscriptions.Add(subscription);
|
||||
}
|
||||
|
||||
subscription.PlanCode = NormalizeCode(command.PlanCode);
|
||||
subscription.Status = command.Status;
|
||||
subscription.StartsAt = command.StartsAt;
|
||||
subscription.ExpiresAt = command.ExpiresAt;
|
||||
subscription.BillingCycle = Normalize(command.BillingCycle);
|
||||
subscription.AmountCents = command.AmountCents;
|
||||
subscription.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
AddAudit(dbContext, actor, "platform.tenant.subscription.updated", command.TenantId, new
|
||||
{
|
||||
subscription.PlanCode,
|
||||
subscription.Status,
|
||||
subscription.ExpiresAt
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToSubscriptionItem(subscription);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDomainList> GetDomainsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform domain list", async dbContext =>
|
||||
{
|
||||
var domains = dbContext.TenantDomains.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
domains = domains.Where(domain => domain.Status == ParseDomainStatus(query.Status));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
domains = domains.Where(domain => domain.Host.Contains(search));
|
||||
}
|
||||
|
||||
return new PlatformDomainList(await domains
|
||||
.OrderByDescending(domain => domain.UpdatedAt)
|
||||
.Take(Limit(query.Limit))
|
||||
.Select(domain => ToDomainItem(domain))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDomainRecheckResult> RecheckDomainAsync(
|
||||
PlatformAdminActor actor,
|
||||
Guid domainId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform domain recheck", async dbContext =>
|
||||
{
|
||||
var domain = await dbContext.TenantDomains.SingleOrDefaultAsync(domain => domain.Id == domainId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Tenant domain was not found.", "domain_not_found");
|
||||
domain.Status = domain.Status == TenantDomainStatus.Disabled ? TenantDomainStatus.Disabled : TenantDomainStatus.Pending;
|
||||
domain.LastCheckedAt = DateTimeOffset.UtcNow;
|
||||
domain.LastFailureReason = null;
|
||||
dbContext.BackgroundJobs.Add(new BackgroundJob
|
||||
{
|
||||
TenantId = domain.TenantId,
|
||||
JobType = "tenant_domain_recheck",
|
||||
Payload = JsonSerializer.SerializeToElement(new { domain.Id, domain.Host }),
|
||||
MaxRetries = 3
|
||||
});
|
||||
AddAudit(dbContext, actor, "platform.tenant_domain.recheck_requested", domain.TenantId, new { domain.Id, domain.Host });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new PlatformDomainRecheckResult(domain.Id, domain.Status, domain.LastCheckedAt.Value);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformStaffList> GetStaffAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform staff list", async dbContext =>
|
||||
{
|
||||
var roleRows = await (
|
||||
from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking()
|
||||
join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||
join user in dbContext.Users.AsNoTracking() on userRole.UserId equals user.Id
|
||||
select new { user, role.Code })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var items = roleRows
|
||||
.GroupBy(row => row.user.Id)
|
||||
.Select(group => ToStaffItem(group.First().user, group.Select(row => row.Code).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray()))
|
||||
.OrderBy(item => item.Email ?? item.PhoneMasked ?? item.UserId.ToString())
|
||||
.Take(Limit(query.Limit))
|
||||
.ToArray();
|
||||
return new PlatformStaffList(items);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformStaffItem> UpsertStaffAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpsertPlatformStaffCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform staff upsert", async dbContext =>
|
||||
{
|
||||
var user = command.UserId.HasValue
|
||||
? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken)
|
||||
: await dbContext.Users.SingleOrDefaultAsync(item =>
|
||||
(!string.IsNullOrWhiteSpace(command.Email) && item.Email == command.Email) ||
|
||||
(!string.IsNullOrWhiteSpace(command.Phone) && item.Phone == command.Phone),
|
||||
cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
user = new User
|
||||
{
|
||||
Email = Normalize(command.Email),
|
||||
NormalizedEmail = Normalize(command.Email)?.ToUpperInvariant(),
|
||||
UserName = Normalize(command.Email) ?? Normalize(command.Phone),
|
||||
NormalizedUserName = (Normalize(command.Email) ?? Normalize(command.Phone))?.ToUpperInvariant(),
|
||||
Phone = Normalize(command.Phone),
|
||||
PhoneNumber = Normalize(command.Phone),
|
||||
Name = Normalize(command.Name),
|
||||
PrimaryRole = "platform_admin",
|
||||
Status = command.Status,
|
||||
ForcePasswordChange = true
|
||||
};
|
||||
dbContext.Users.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.Email = Normalize(command.Email) ?? user.Email;
|
||||
user.NormalizedEmail = user.Email?.ToUpperInvariant();
|
||||
user.Phone = Normalize(command.Phone) ?? user.Phone;
|
||||
user.PhoneNumber = user.Phone;
|
||||
user.Name = Normalize(command.Name) ?? user.Name;
|
||||
user.Status = command.Status;
|
||||
user.PrimaryRole = "platform_admin";
|
||||
}
|
||||
|
||||
var roleIds = command.RoleIds.Distinct().ToArray();
|
||||
var roleCount = await dbContext.PlatformBackendRoles.CountAsync(role => roleIds.Contains(role.Id), cancellationToken);
|
||||
if (roleCount != roleIds.Length)
|
||||
{
|
||||
throw new PlatformAdminException("One or more platform roles were not found.", "role_not_found");
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await dbContext.PlatformBackendUserRoles.Where(binding => binding.UserId == user.Id).ExecuteDeleteAsync(cancellationToken);
|
||||
dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole
|
||||
{
|
||||
UserId = user.Id,
|
||||
RoleId = roleId
|
||||
}));
|
||||
AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var roleCodes = await dbContext.PlatformBackendRoles.AsNoTracking()
|
||||
.Where(role => roleIds.Contains(role.Id))
|
||||
.Select(role => role.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return ToStaffItem(user, roleCodes);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformStaffItem> UpdateStaffStatusAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpdatePlatformStaffStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform staff status update", async dbContext =>
|
||||
{
|
||||
var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found");
|
||||
var from = user.Status;
|
||||
user.Status = command.Status;
|
||||
AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, new { From = from, To = command.Status, command.Reason });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var roleCodes = await (
|
||||
from binding in dbContext.PlatformBackendUserRoles.AsNoTracking()
|
||||
join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id
|
||||
where binding.UserId == user.Id
|
||||
select role.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return ToStaffItem(user, roleCodes);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformAuditLogList> GetAuditLogsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform audit log list", async dbContext =>
|
||||
{
|
||||
var logs = dbContext.AuditLogs.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
logs = logs.Where(log => log.Action.Contains(search) || (log.TargetType != null && log.TargetType.Contains(search)));
|
||||
}
|
||||
|
||||
return new PlatformAuditLogList(await logs
|
||||
.OrderByDescending(log => log.CreatedAt)
|
||||
.Take(Limit(query.Limit))
|
||||
.Select(log => new PlatformAuditLogItem(
|
||||
log.Id,
|
||||
log.TenantId,
|
||||
log.ActorUserId,
|
||||
log.Action,
|
||||
log.TargetType,
|
||||
log.TargetId,
|
||||
log.Details,
|
||||
log.IpAddress,
|
||||
log.UserAgent,
|
||||
log.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformAuditAlertList> GetAuditAlertsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform audit alert list", async dbContext =>
|
||||
{
|
||||
var alerts = dbContext.PlatformAuditAlerts.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
alerts = alerts.Where(alert => alert.Status == ParseAuditAlertStatus(query.Status));
|
||||
}
|
||||
|
||||
return new PlatformAuditAlertList(await alerts
|
||||
.OrderByDescending(alert => alert.LastSeenAt)
|
||||
.Take(Limit(query.Limit))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformAuditAlert> UpdateAuditAlertStatusAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpdatePlatformAuditAlertStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform audit alert status update", async dbContext =>
|
||||
{
|
||||
var alert = await dbContext.PlatformAuditAlerts.SingleOrDefaultAsync(item => item.Id == command.AlertId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform audit alert was not found.", "audit_alert_not_found");
|
||||
alert.Status = command.Status;
|
||||
alert.ResolutionNote = Normalize(command.ResolutionNote);
|
||||
if (command.Status == PlatformAuditAlertStatus.Acknowledged)
|
||||
{
|
||||
alert.AcknowledgedBy = actor.UserId;
|
||||
alert.AcknowledgedAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
else if (command.Status is PlatformAuditAlertStatus.Resolved or PlatformAuditAlertStatus.Ignored)
|
||||
{
|
||||
alert.ResolvedBy = actor.UserId;
|
||||
alert.ResolvedAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
AddAudit(dbContext, actor, "platform.audit_alert.status_changed", alert.Id, new { alert.Status, command.ResolutionNote });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return alert;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningChannelList> GetDunningChannelsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning channel list", async dbContext =>
|
||||
{
|
||||
var channels = dbContext.PlatformDunningNotificationChannels.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
channels = channels.Where(channel =>
|
||||
channel.ChannelCode.Contains(search) ||
|
||||
channel.Name.Contains(search));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
var enabled = ParseEnabledStatus(query.Status);
|
||||
channels = channels.Where(channel => channel.Enabled == enabled);
|
||||
}
|
||||
|
||||
return new PlatformDunningChannelList(await channels
|
||||
.OrderByDescending(channel => channel.Enabled)
|
||||
.ThenBy(channel => channel.MinReminderLevel)
|
||||
.ThenBy(channel => channel.ChannelCode)
|
||||
.Take(Limit(query.Limit))
|
||||
.Select(channel => ToDunningChannelItem(channel))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningChannelItem> UpsertDunningChannelAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpsertPlatformDunningChannelCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning channel upsert", async dbContext =>
|
||||
{
|
||||
var code = NormalizeCode(command.ChannelCode);
|
||||
var channel = command.ChannelId.HasValue
|
||||
? await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken)
|
||||
: await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken);
|
||||
if (channel is null)
|
||||
{
|
||||
channel = new PlatformDunningNotificationChannel { ChannelCode = code };
|
||||
dbContext.PlatformDunningNotificationChannels.Add(channel);
|
||||
}
|
||||
|
||||
channel.ChannelCode = code;
|
||||
channel.Name = command.Name.Trim();
|
||||
channel.Description = Normalize(command.Description);
|
||||
channel.Enabled = command.Enabled;
|
||||
channel.Provider = command.Provider;
|
||||
channel.WebhookUrl = command.WebhookUrl.Trim();
|
||||
channel.SecretRef = Normalize(command.SecretRef);
|
||||
channel.ReminderTypes = NormalizeArray(command.ReminderTypes, ["overdue", "final_notice"]);
|
||||
channel.ReminderChannels = NormalizeArray(command.ReminderChannels, ["internal"]);
|
||||
channel.MinReminderLevel = Math.Clamp(command.MinReminderLevel, 1, 20);
|
||||
channel.TenantIds = command.TenantIds.Where(id => id != Guid.Empty).Distinct().ToArray();
|
||||
channel.TimeoutSeconds = Math.Clamp(command.TimeoutSeconds, 1, 60);
|
||||
channel.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
AddAudit(dbContext, actor, "platform.dunning_channel.upserted", channel.Id, new
|
||||
{
|
||||
channel.ChannelCode,
|
||||
channel.Name,
|
||||
channel.Enabled,
|
||||
channel.Provider,
|
||||
Webhook = MaskWebhook(channel.WebhookUrl),
|
||||
channel.SecretRef
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToDunningChannelItem(channel);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningChannelItem> DisableDunningChannelAsync(
|
||||
PlatformAdminActor actor,
|
||||
DisablePlatformDunningChannelCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning channel disable", async dbContext =>
|
||||
{
|
||||
var channel = await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform dunning channel was not found.", "dunning_channel_not_found");
|
||||
channel.Enabled = false;
|
||||
AddAudit(dbContext, actor, "platform.dunning_channel.disabled", channel.Id, new
|
||||
{
|
||||
channel.ChannelCode,
|
||||
command.Reason
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToDunningChannelItem(channel);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningEventList> GetDunningEventsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning event list", async dbContext =>
|
||||
{
|
||||
var events = dbContext.PlatformDunningNotificationEvents.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
events = events.Where(item => item.Status == ParseDunningEventStatus(query.Status));
|
||||
}
|
||||
|
||||
return new PlatformDunningEventList(await events
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Limit(query.Limit))
|
||||
.Select(item => ToDunningEventItem(item))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningEventItem> GetDunningEventDetailAsync(
|
||||
PlatformAdminActor actor,
|
||||
Guid eventId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning event detail", async dbContext =>
|
||||
{
|
||||
var item = await dbContext.PlatformDunningNotificationEvents.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == eventId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
|
||||
return ToDunningEventItem(item);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningEventItem> RetryDunningEventAsync(
|
||||
PlatformAdminActor actor,
|
||||
RetryPlatformDunningEventCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning event retry", async dbContext =>
|
||||
{
|
||||
var item = await dbContext.PlatformDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
|
||||
item.Status = PlatformDunningNotificationStatus.Pending;
|
||||
item.NextAttemptAt = DateTimeOffset.UtcNow;
|
||||
item.LastError = null;
|
||||
AddAudit(dbContext, actor, "platform.dunning_event.retry_requested", item.Id, new
|
||||
{
|
||||
item.TenantId,
|
||||
item.ChannelId,
|
||||
item.ReminderId,
|
||||
item.InvoiceId,
|
||||
command.Reason
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToDunningEventItem(item);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AssertPlatformPermissionAsync(
|
||||
PlatformAdminActor actor,
|
||||
string permissionCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||
if (access.UserId != actor.UserId || !access.HasPlatformPermission(permissionCode))
|
||||
{
|
||||
throw new PlatformAdminException("Platform admin access is required.", "platform_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private Task<TResult> ExecuteSystemAsync<TResult>(
|
||||
string reason,
|
||||
Func<TikuDbContext, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
null,
|
||||
reason,
|
||||
async (provider, _) => await operation(provider.GetRequiredService<TikuDbContext>()),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.Tenants.AnyAsync(tenant => tenant.Id == tenantId && tenant.Mode != TenantMode.PlatformOwned, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, object details)
|
||||
{
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
ActorUserId = actor.UserId,
|
||||
Action = action,
|
||||
TargetType = action.Split('.')[1],
|
||||
TargetId = targetId.ToString("N"),
|
||||
Details = JsonSerializer.SerializeToElement(details)
|
||||
});
|
||||
}
|
||||
|
||||
private static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount, DateTimeOffset? subscriptionExpiresAt) =>
|
||||
new(
|
||||
tenant.Id,
|
||||
tenant.Slug,
|
||||
tenant.Name,
|
||||
tenant.LegalName,
|
||||
tenant.Status,
|
||||
tenant.Mode,
|
||||
tenant.BillingStatus,
|
||||
subscriptionExpiresAt,
|
||||
domainCount,
|
||||
tenant.CreatedAt,
|
||||
tenant.UpdatedAt);
|
||||
|
||||
private static PlatformTenantDomainItem ToDomainItem(TenantDomain domain) =>
|
||||
new(
|
||||
domain.Id,
|
||||
domain.TenantId,
|
||||
domain.Host,
|
||||
domain.DomainType,
|
||||
domain.Status,
|
||||
domain.IsPrimary,
|
||||
domain.VerifiedAt,
|
||||
domain.DnsVerifiedAt,
|
||||
domain.TlsReadyAt,
|
||||
domain.LastCheckedAt,
|
||||
domain.LastFailureReason);
|
||||
|
||||
private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSubscription subscription) =>
|
||||
new(
|
||||
subscription.Id,
|
||||
subscription.TenantId,
|
||||
subscription.PlanCode,
|
||||
subscription.Status,
|
||||
subscription.StartsAt,
|
||||
subscription.ExpiresAt,
|
||||
subscription.BillingCycle,
|
||||
subscription.AmountCents,
|
||||
subscription.Metadata);
|
||||
|
||||
private static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) =>
|
||||
new(
|
||||
profile.TenantId,
|
||||
profile.BillingName,
|
||||
profile.TaxId,
|
||||
profile.ContactName,
|
||||
MaskPhone(profile.ContactPhone),
|
||||
profile.ContactEmail,
|
||||
profile.BillingAddress,
|
||||
profile.InvoiceTitle,
|
||||
profile.InvoiceType,
|
||||
profile.BankName,
|
||||
profile.BankAccountMasked,
|
||||
profile.Metadata);
|
||||
|
||||
private static PlatformDunningChannelItem ToDunningChannelItem(PlatformDunningNotificationChannel channel) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.ChannelCode,
|
||||
channel.Name,
|
||||
channel.Description,
|
||||
channel.Enabled,
|
||||
channel.Provider,
|
||||
MaskWebhook(channel.WebhookUrl),
|
||||
channel.SecretRef,
|
||||
channel.ReminderTypes,
|
||||
channel.ReminderChannels,
|
||||
channel.MinReminderLevel,
|
||||
channel.TenantIds,
|
||||
channel.TimeoutSeconds,
|
||||
channel.Metadata,
|
||||
channel.CreatedAt,
|
||||
channel.UpdatedAt);
|
||||
|
||||
private static PlatformDunningEventItem ToDunningEventItem(PlatformDunningNotificationEvent item) =>
|
||||
new(
|
||||
item.Id,
|
||||
item.TenantId,
|
||||
item.ChannelId,
|
||||
item.ReminderId,
|
||||
item.InvoiceId,
|
||||
item.Provider,
|
||||
item.Status,
|
||||
item.Attempts,
|
||||
item.ScheduledAt,
|
||||
item.NextAttemptAt,
|
||||
item.LastAttemptAt,
|
||||
item.SentAt,
|
||||
item.LastError,
|
||||
item.LastHttpCode,
|
||||
item.LastResponseSummary,
|
||||
item.RequestPayload,
|
||||
item.Metadata,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt);
|
||||
|
||||
private static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection<string> roleCodes) =>
|
||||
new(
|
||||
user.Id,
|
||||
user.Name,
|
||||
MaskPhone(user.Phone),
|
||||
user.Email,
|
||||
user.Status,
|
||||
roleCodes,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt);
|
||||
|
||||
private static int Limit(int? limit) => Math.Clamp(limit ?? 50, 1, 200);
|
||||
|
||||
private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant();
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement value) =>
|
||||
value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
|
||||
|
||||
private static string? MaskPhone(string? phone)
|
||||
{
|
||||
var value = Normalize(phone);
|
||||
return value is { Length: >= 7 }
|
||||
? $"{value[..3]}****{value[^4..]}"
|
||||
: value;
|
||||
}
|
||||
|
||||
private static string? MaskBankAccount(string? account)
|
||||
{
|
||||
var value = Normalize(account);
|
||||
return value is { Length: > 8 }
|
||||
? $"****{value[^4..]}"
|
||||
: value;
|
||||
}
|
||||
|
||||
private static string MaskWebhook(string webhookUrl)
|
||||
{
|
||||
var value = Normalize(webhookUrl) ?? string.Empty;
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return value.Length <= 16 ? "****" : $"{value[..8]}****{value[^4..]}";
|
||||
}
|
||||
|
||||
return $"{uri.Scheme}://{uri.Host}/****";
|
||||
}
|
||||
|
||||
private static string[] NormalizeArray(IReadOnlyCollection<string> values, string[] fallback)
|
||||
{
|
||||
var normalized = values
|
||||
.Select(Normalize)
|
||||
.Where(value => value is not null)
|
||||
.Select(value => value!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return normalized.Length == 0 ? fallback : normalized;
|
||||
}
|
||||
|
||||
private static bool ParseEnabledStatus(string status)
|
||||
{
|
||||
return NormalizeCode(status) switch
|
||||
{
|
||||
"enabled" or "active" or "true" => true,
|
||||
"disabled" or "inactive" or "false" => false,
|
||||
_ => throw InvalidStatus(status)
|
||||
};
|
||||
}
|
||||
|
||||
private static TenantStatus ParseTenantStatus(string status) =>
|
||||
Enum.TryParse<TenantStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
||||
|
||||
private static TenantDomainStatus ParseDomainStatus(string status) =>
|
||||
Enum.TryParse<TenantDomainStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
||||
|
||||
private static PlatformSaasPlanStatus ParsePlanStatus(string status) =>
|
||||
Enum.TryParse<PlatformSaasPlanStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
||||
|
||||
private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) =>
|
||||
Enum.TryParse<PlatformAuditAlertStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
||||
|
||||
private static PlatformDunningNotificationStatus ParseDunningEventStatus(string status) =>
|
||||
Enum.TryParse<PlatformDunningNotificationStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
||||
|
||||
private static PlatformAdminException InvalidStatus(string status) =>
|
||||
new($"Unsupported status '{status}'.", "invalid_status");
|
||||
}
|
||||
Reference in New Issue
Block a user