Files
flatrender/services/identity/FlatRender.IdentitySvc/Application/Services/AdminService.cs
T
soroush.asadi 3acd366fda
Build backend images / build content-svc (push) Failing after 1m7s
Build backend images / build file-svc (push) Failing after 50s
Build backend images / build gateway (push) Failing after 59s
Build backend images / build identity-svc (push) Failing after 56s
Build backend images / build notification-svc (push) Failing after 1m0s
Build backend images / build render-svc (push) Failing after 1m0s
Build backend images / build studio-svc (push) Failing after 56s
feat(admin): music library admin + fix CRM analytics UTC
- /admin/music: list / upload / delete studio audio tracks (content-svc
  GET/POST/DELETE /v1/music) — fills the legacy music-library gap
- fix: CRM analytics coerced query-bound dates to UTC (Npgsql timestamptz
  rejects Kind=Unspecified) — endpoint was returning 400

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:17:14 +03:30

139 lines
5.9 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);
}
// ── 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();
}
}