feat(identity+admin): CRM analytics + customer notes + user power-actions
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
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
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>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using FlatRender.IdentitySvc.Application.Services;
|
||||
using FlatRender.IdentitySvc.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace FlatRender.IdentitySvc.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public class AdminController(AdminService svc) : ControllerBase
|
||||
{
|
||||
private Guid TenantId =>
|
||||
Guid.TryParse(User.FindFirst("tenant_id")?.Value, out var t)
|
||||
? t : Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
// ── CRM analytics ────────────────────────────────────────────────────────
|
||||
[HttpGet("v1/admin/crm/analytics")]
|
||||
public async Task<IActionResult> Crm([FromQuery] DateTime? start, [FromQuery] DateTime? end)
|
||||
{
|
||||
var s = start ?? DateTime.UtcNow.AddDays(-30);
|
||||
var e = end ?? DateTime.UtcNow;
|
||||
return Ok(await svc.GetCrmAnalyticsAsync(TenantId, s, e));
|
||||
}
|
||||
|
||||
// ── CRM notes / tags ───────────────────────────────────────────────────────
|
||||
[HttpGet("v1/users/{userId:guid}/crm")]
|
||||
public async Task<IActionResult> GetCrm(Guid userId) => Ok(await svc.GetUserCrmAsync(userId));
|
||||
|
||||
[HttpPut("v1/users/{userId:guid}/crm")]
|
||||
public async Task<IActionResult> PutCrm(Guid userId, [FromBody] UpsertUserCrmRequest req)
|
||||
=> Ok(await svc.UpsertUserCrmAsync(userId, req));
|
||||
|
||||
// ── Power-actions ──────────────────────────────────────────────────────────
|
||||
[HttpPost("v1/users/{userId:guid}/balance")]
|
||||
public async Task<IActionResult> Balance(Guid userId, [FromBody] SetBalanceRequest req)
|
||||
{
|
||||
await svc.SetBalanceAsync(userId, req.AmountMinor, req.Add);
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
[HttpPost("v1/users/{userId:guid}/password")]
|
||||
public async Task<IActionResult> Password(Guid userId, [FromBody] ResetPasswordRequest req)
|
||||
{
|
||||
await svc.ResetPasswordAsync(userId, req.NewPassword);
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
[HttpPost("v1/users/{userId:guid}/charge")]
|
||||
public async Task<IActionResult> Charge(Guid userId, [FromBody] AddChargeRequest req)
|
||||
{
|
||||
await svc.AddChargeAsync(userId, req.Seconds, req.RenderCount);
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
[HttpPost("v1/users/{userId:guid}/moderator")]
|
||||
public async Task<IActionResult> Moderator(Guid userId, [FromBody] SetFlagRequest req)
|
||||
{
|
||||
await svc.SetModeratorAsync(userId, req.Enabled);
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
[HttpPost("v1/users/{userId:guid}/grant-plan")]
|
||||
public async Task<IActionResult> GrantPlan(Guid userId, [FromBody] GrantPlanDaysRequest req)
|
||||
{
|
||||
try
|
||||
{
|
||||
await svc.GrantPlanDaysAsync(userId, req.PlanId, req.Days);
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return BadRequest(new { error = new { message = ex.Message } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace FlatRender.IdentitySvc.Domain.Entities;
|
||||
|
||||
/// <summary>Lightweight CRM overlay for a customer (tags / note / pipeline status).</summary>
|
||||
public class UserCrm
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public string[] Tags { get; set; } = [];
|
||||
public string? Note { get; set; }
|
||||
public string Status { get; set; } = "new"; // new | contacted | customer | churned
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -28,6 +28,9 @@ public class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : Db
|
||||
public DbSet<Discount> Discounts => Set<Discount>();
|
||||
public DbSet<UsedDiscount> UsedDiscounts => Set<UsedDiscount>();
|
||||
|
||||
// CRM
|
||||
public DbSet<UserCrm> UserCrms => Set<UserCrm>();
|
||||
|
||||
// Gamification
|
||||
public DbSet<Quest> Quests => Set<Quest>();
|
||||
public DbSet<UserQuestProgress> UserQuestProgresses => Set<UserQuestProgress>();
|
||||
@@ -47,6 +50,12 @@ public class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : Db
|
||||
ConfigureUsers(mb);
|
||||
ConfigureBilling(mb);
|
||||
ConfigureGamification(mb);
|
||||
|
||||
mb.Entity<UserCrm>(e =>
|
||||
{
|
||||
e.ToTable("user_crm");
|
||||
e.HasKey(x => x.UserId);
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureTenants(ModelBuilder mb)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace FlatRender.IdentitySvc.Models;
|
||||
|
||||
// ── CRM analytics (acquisition / conversion funnel) ──────────────────────────
|
||||
|
||||
public record CrmDailyPoint(string Date, int Signups, int Buyers, long RevenueMinor);
|
||||
|
||||
public record CrmAnalyticsResponse(
|
||||
int TotalSignups,
|
||||
int Buyers,
|
||||
int NonBuyers,
|
||||
double ConversionRate,
|
||||
long RevenueMinor,
|
||||
int PayingUsersAllTime,
|
||||
List<CrmDailyPoint> Daily
|
||||
);
|
||||
|
||||
// ── CRM notes / tags per customer ────────────────────────────────────────────
|
||||
|
||||
public record UserCrmResponse(string[] Tags, string? Note, string Status);
|
||||
|
||||
public record UpsertUserCrmRequest(string[]? Tags, string? Note, string? Status);
|
||||
|
||||
// ── User admin power-actions ─────────────────────────────────────────────────
|
||||
|
||||
public record SetBalanceRequest(long AmountMinor, bool Add); // Add=false → set absolute
|
||||
public record ResetPasswordRequest(string NewPassword);
|
||||
public record AddChargeRequest(int Seconds, int RenderCount); // grant render seconds / daily renders
|
||||
public record GrantPlanDaysRequest(Guid PlanId, int Days);
|
||||
public record SetFlagRequest(bool Enabled);
|
||||
@@ -78,6 +78,7 @@ builder.Services.AddScoped<IPlanService, PlanService>();
|
||||
builder.Services.AddScoped<IDiscountService, DiscountService>();
|
||||
builder.Services.AddScoped<IGamificationService, GamificationService>();
|
||||
builder.Services.AddScoped<IPaymentService, PaymentService>();
|
||||
builder.Services.AddScoped<AdminService>();
|
||||
|
||||
// ── HTTP clients ───────────────────────────────────────────────────────────
|
||||
builder.Services.AddHttpClient("zarinpal", client =>
|
||||
|
||||
Reference in New Issue
Block a user