Files
flatrender/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs
T
soroush.asadi 81912cac66
Build backend images / build content-svc (push) Failing after 14s
Build backend images / build file-svc (push) Failing after 1m28s
Build backend images / build gateway (push) Failing after 1m43s
Build backend images / build identity-svc (push) Failing after 3m0s
Build backend images / build notification-svc (push) Failing after 51s
Build backend images / build render-svc (push) Failing after 1m3s
Build backend images / build studio-svc (push) Failing after 1m1s
feat(render): full-screen render page, one-active-render limit, app-wide progress
Concurrent-render ceiling (a user runs 1 render at a time unless granted more):
- Identity: TokenService emits max_renders claim from User.ParallelRenderingCeiling
- Identity: admin POST /v1/users/{id}/render-slots (AdminService.SetRenderSlotsAsync,
  clamped 1..50) — gamification or admin raises a user's ceiling
- render-svc: middleware reads max_renders (default 1); CreateJob rejects with 409
  active_render_limit when active jobs >= ceiling
- render-svc: db.CountActiveJobs + ListActiveJobs; GET /v1/renders/active returns
  in-flight renders + can_start_new

Full-screen render page (replaces the modal):
- /studio/render/[projectId]: config (resolution/fps) → live preview + progress →
  download; resumes this project's in-flight render on mount; blocks when another
  render is active; reads ?preset=
- StudioTopBar export menu now navigates to the page; RenderModal deleted (dead)

App-wide minimal progress:
- GlobalRenderProgress pill mounted in the locale layout for authed users; polls
  /api/render/active every 4s, shows thumbnail + step + % on every page, click →
  the render page; hidden on the render page and when idle

Admin: UserActions gains a "concurrent render slots" control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:48:05 +03:30

210 lines
8.8 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)
{
// Query-bound dates arrive Kind=Unspecified; Npgsql requires UTC for timestamptz.
start = DateTime.SpecifyKind(start.Date, DateTimeKind.Utc);
end = DateTime.SpecifyKind(end.Date.AddDays(1), DateTimeKind.Utc); // 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);
}
// ── Plan statistics ──────────────────────────────────────────────────────
public async Task<List<PlanStatRow>> GetPlanStatisticsAsync(Guid tenantId)
{
var now = DateTime.UtcNow;
// Pull flat rows then aggregate in memory — EF can't translate a conditional
// Count(predicate) inside a grouped Select.
var rows = await db.UserPlans
.Where(p => p.TenantId == tenantId)
.Select(p => new { p.PlanName, p.ExpiresAt, p.CancelledAt, p.PriceMinorPaid })
.ToListAsync();
return rows.GroupBy(r => r.PlanName)
.Select(g => new PlanStatRow(
g.Key,
g.Count(),
g.Count(x => x.ExpiresAt > now && x.CancelledAt == null),
g.Sum(x => x.PriceMinorPaid)))
.OrderByDescending(r => r.RevenueMinor)
.ToList();
}
// ── 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);
}
// ── Render charge (daily render limit) — consume / refund ─────────────────
// Convention: MaxDailyRenderCount == 0 means UNLIMITED (no enforcement).
public async Task<(bool Allowed, int Remaining)> ConsumeRenderChargeAsync(Guid userId)
{
var u = await db.Users.FindAsync(userId);
if (u == null) return (false, 0);
if (u.MaxDailyRenderCount <= 0) return (true, -1); // unlimited
// Lazy daily reset (UTC day boundary).
var today = DateTime.UtcNow.Date;
if (u.DailyRendersResetAt == null || u.DailyRendersResetAt.Value.Date < today)
{
u.DailyRemainRenderCount = u.MaxDailyRenderCount;
u.DailyRendersResetAt = DateTime.UtcNow;
}
if (u.DailyRemainRenderCount <= 0)
{
await db.SaveChangesAsync(); // persist any reset
return (false, 0);
}
u.DailyRemainRenderCount -= 1;
u.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return (true, u.DailyRemainRenderCount);
}
public async Task RefundRenderChargeAsync(Guid userId)
{
var u = await db.Users.FindAsync(userId);
if (u == null || u.MaxDailyRenderCount <= 0) return; // nothing to refund / unlimited
u.DailyRemainRenderCount = Math.Min(u.DailyRemainRenderCount + 1, u.MaxDailyRenderCount);
u.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
// ── 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();
}
/// <summary>
/// Set how many renders a user may run concurrently. The render service reads
/// this from the JWT (max_renders claim); takes effect on the user's next token
/// refresh. Clamped to [1, 50].
/// </summary>
public async Task SetRenderSlotsAsync(Guid userId, int ceiling)
{
var u = await RequireUser(userId);
u.ParallelRenderingCeiling = Math.Clamp(ceiling, 1, 50);
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();
}
}