Files
flatrender/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs
T
soroush.asadi 62a5121ffe
Build backend images / build content-svc (push) Failing after 56s
Build backend images / build file-svc (push) Failing after 54s
Build backend images / build gateway (push) Failing after 1m1s
Build backend images / build identity-svc (push) Failing after 55s
Build backend images / build notification-svc (push) Failing after 54s
Build backend images / build render-svc (push) Failing after 52s
Build backend images / build studio-svc (push) Failing after 1m2s
feat(identity+admin): CRM analytics + customer notes + user power-actions
Modeled on the legacy DivineGateWeb admin (CRM + Security/* actions):
- identity-svc AdminService + AdminController (admin-gated):
  - GET /v1/admin/crm/analytics — signups/buyers/conversion/revenue + daily series
    (from identity.users + identity.payments)
  - GET/PUT /v1/users/{id}/crm — tags / note / pipeline status (user_crm table, mig 20)
  - power-actions: POST /v1/users/{id}/{balance,password,charge,moderator,grant-plan}
- admin UI: /admin/crm dashboard (funnel cards + daily signup/revenue bars);
  per-user "مدیریت" modal in Users (balance, render charge, plan days, password,
  moderator, CRM notes)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:59:07 +03:30

138 lines
5.7 KiB
C#

using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Application.Services;
/// <summary>Admin CRM analytics, customer notes/tags, and per-user power-actions.</summary>
public class AdminService(IdentityDbContext db)
{
// ── CRM acquisition / conversion analytics ──────────────────────────────
public async Task<CrmAnalyticsResponse> GetCrmAnalyticsAsync(Guid tenantId, DateTime start, DateTime end)
{
start = start.Date;
end = end.Date.AddDays(1); // inclusive of the end day
if ((end - start).TotalDays > 366) end = start.AddDays(366);
var signups = await db.Users
.Where(u => u.TenantId == tenantId && u.DeletedAt == null && u.RegisterDate >= start && u.RegisterDate < end)
.Select(u => new { u.Id, u.RegisterDate })
.ToListAsync();
var ids = signups.Select(s => s.Id).ToList();
var buyerIds = await db.Payments
.Where(p => p.TenantId == tenantId && p.Status == PaymentStatus.Succeeded && ids.Contains(p.UserId))
.Select(p => p.UserId).Distinct().ToListAsync();
var pays = await db.Payments
.Where(p => p.TenantId == tenantId && p.Status == PaymentStatus.Succeeded && p.CreatedAt >= start && p.CreatedAt < end)
.Select(p => new { p.UserId, p.AmountMinor, p.CreatedAt })
.ToListAsync();
var payingAllTime = await db.Payments
.Where(p => p.TenantId == tenantId && p.Status == PaymentStatus.Succeeded)
.Select(p => p.UserId).Distinct().CountAsync();
var daily = new List<CrmDailyPoint>();
for (var d = start; d < end; d = d.AddDays(1))
{
var de = d.AddDays(1);
var dayPays = pays.Where(p => p.CreatedAt >= d && p.CreatedAt < de).ToList();
daily.Add(new CrmDailyPoint(
d.ToString("yyyy-MM-dd"),
signups.Count(u => u.RegisterDate >= d && u.RegisterDate < de),
dayPays.Select(p => p.UserId).Distinct().Count(),
dayPays.Sum(p => p.AmountMinor)));
}
var total = signups.Count;
var buyers = buyerIds.Count;
return new CrmAnalyticsResponse(
total, buyers, total - buyers,
total > 0 ? Math.Round((double)buyers / total * 100, 1) : 0,
pays.Sum(p => p.AmountMinor), payingAllTime, daily);
}
// ── CRM notes / tags ────────────────────────────────────────────────────
public async Task<UserCrmResponse> GetUserCrmAsync(Guid userId)
{
var c = await db.Set<UserCrm>().FindAsync(userId);
return c == null
? new UserCrmResponse([], null, "new")
: new UserCrmResponse(c.Tags, c.Note, c.Status);
}
public async Task<UserCrmResponse> UpsertUserCrmAsync(Guid userId, UpsertUserCrmRequest req)
{
var c = await db.Set<UserCrm>().FindAsync(userId);
var isNew = c == null;
c ??= new UserCrm { UserId = userId };
if (req.Tags != null) c.Tags = req.Tags;
if (req.Note != null) c.Note = req.Note;
if (req.Status != null) c.Status = req.Status;
c.UpdatedAt = DateTime.UtcNow;
if (isNew) db.Add(c);
await db.SaveChangesAsync();
return new UserCrmResponse(c.Tags, c.Note, c.Status);
}
// ── Per-user power-actions ──────────────────────────────────────────────
private async Task<User> RequireUser(Guid id) =>
await db.Users.FindAsync(id) ?? throw new KeyNotFoundException("User not found");
public async Task SetBalanceAsync(Guid userId, long amountMinor, bool add)
{
var u = await RequireUser(userId);
u.BalanceMinor = add ? u.BalanceMinor + amountMinor : amountMinor;
u.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task ResetPasswordAsync(Guid userId, string newPassword)
{
var u = await RequireUser(userId);
u.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
u.PasswordSetAt = DateTime.UtcNow;
u.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task AddChargeAsync(Guid userId, int seconds, int renderCount)
{
var u = await RequireUser(userId);
if (seconds != 0) u.UserDailyFreeChargeSec += seconds;
if (renderCount != 0)
{
u.MaxDailyRenderCount += renderCount;
u.DailyRemainRenderCount += renderCount;
}
u.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task SetModeratorAsync(Guid userId, bool enabled)
{
var u = await RequireUser(userId);
u.IsTenantAdmin = enabled;
u.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task GrantPlanDaysAsync(Guid userId, Guid planId, int days)
{
var plan = await db.UserPlans
.Where(p => p.UserId == userId && (planId == Guid.Empty || p.PlanId == planId))
.OrderByDescending(p => p.ExpiresAt).FirstOrDefaultAsync()
?? throw new KeyNotFoundException("No plan found for this user to extend");
var baseDate = plan.ExpiresAt > DateTime.UtcNow ? plan.ExpiresAt : DateTime.UtcNow;
plan.ExpiresAt = baseDate.AddDays(days);
plan.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}