feat: V2 microservices stack — backend services, gateway, JWT auth

Add full V2 architecture: identity, content, studio (.NET 10) and file,
render, notification, gateway (Go) services with vendored deps, plus DB
migrations, event/API contracts, and an init-db script.

Wire the Next.js frontend to the gateway: server-side JWT auth routes
(login/register/refresh/logout/me), gateway fetch helper, and session/
cookie/jwt helpers under src/lib.

Containerize the stack via docker-compose.v2.yml and per-service
Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and
MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via
next/font/local to avoid Google Fonts (geo-blocked).

Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-29 23:29:31 +03:30
parent 53ea78a00d
commit 90ac0b81d1
7636 changed files with 3707504 additions and 240 deletions
@@ -0,0 +1,501 @@
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
using OtpNet; // Otp.NET package
namespace FlatRender.IdentitySvc.Application.Services;
public class AuthService(
IdentityDbContext db,
ITokenService tokenService,
IConfiguration config) : IAuthService
{
private readonly int _refreshTokenDays = int.Parse(config["Jwt:RefreshTokenDays"] ?? "30");
public async Task<RegisterResponse> RegisterAsync(RegisterRequest request, string? ipAddress)
{
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == request.TenantSlug && t.DeletedAt == null)
?? throw new InvalidOperationException("Tenant not found");
if (string.IsNullOrEmpty(request.Email) && string.IsNullOrEmpty(request.PhoneNumber))
throw new ArgumentException("Email or phone number is required");
if (!string.IsNullOrEmpty(request.Email))
{
var exists = await db.Users.AnyAsync(u => u.TenantId == tenant.Id && u.Email == request.Email && u.DeletedAt == null);
if (exists) throw new InvalidOperationException("Email already registered");
}
var user = new User
{
TenantId = tenant.Id,
Email = request.Email?.ToLowerInvariant(),
PhoneNumber = request.PhoneNumber,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
PasswordSetAt = DateTime.UtcNow,
FullName = request.FullName,
RegisterMode = string.IsNullOrEmpty(request.PhoneNumber) ? RegisterMode.Email : RegisterMode.Mobile,
RegisterDate = DateTime.UtcNow,
};
db.Users.Add(user);
await db.SaveChangesAsync();
// Create email verification token
bool verificationRequired = false;
if (!string.IsNullOrEmpty(user.Email))
{
verificationRequired = true;
await CreateConfirmationTokenAsync(user.Id, tenant.Id, TokenPurpose.EmailVerification, user.Email, ipAddress);
}
return new RegisterResponse(user.Id, verificationRequired);
}
public async Task<AuthTokensResponse> LoginAsync(LoginRequest request, string? ipAddress)
{
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == request.TenantSlug && t.DeletedAt == null)
?? throw new UnauthorizedAccessException("Tenant not found");
User? user = null;
if (!string.IsNullOrEmpty(request.Email))
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.Email == request.Email && u.DeletedAt == null);
else if (!string.IsNullOrEmpty(request.PhoneNumber))
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.PhoneNumber == request.PhoneNumber && u.DeletedAt == null);
if (user == null || string.IsNullOrEmpty(user.PasswordHash))
throw new UnauthorizedAccessException("Invalid credentials");
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
throw new UnauthorizedAccessException("Invalid credentials");
if (user.BanAccount && (user.UnblockDate == null || user.UnblockDate > DateTime.UtcNow))
throw new UnauthorizedAccessException("Account is banned");
// Check MFA
var mfaFactor = await db.MfaFactors.FirstOrDefaultAsync(m => m.UserId == user.Id && m.IsVerified && m.IsPrimary);
if (mfaFactor != null)
{
// Return mfa_token — caller must complete challenge
throw new MfaRequiredException(GenerateMfaToken(user.Id, tenant.Id), "MFA required");
}
user.LastLoginAt = DateTime.UtcNow;
user.LastLoginIp = ipAddress;
user.LastActiveDate = DateTime.UtcNow;
var tokens = await CreateSessionAsync(user, tenant, request.DeviceId, request.DeviceName, ipAddress);
return tokens;
}
public async Task<AuthTokensResponse> RefreshAsync(string refreshToken)
{
var tokenHash = tokenService.HashToken(refreshToken);
var session = await db.UserSessions
.Include(s => s.User)
.FirstOrDefaultAsync(s => s.RefreshTokenHash == tokenHash && s.RevokedAt == null && s.ExpiresAt > DateTime.UtcNow)
?? throw new UnauthorizedAccessException("Invalid or expired refresh token");
var tenant = await db.Tenants.FindAsync(session.TenantId)
?? throw new UnauthorizedAccessException("Tenant not found");
// Rotate refresh token
session.RevokedAt = DateTime.UtcNow;
var tokens = await CreateSessionAsync(session.User, tenant, session.DeviceId, session.DeviceName, null, session.Id);
await db.SaveChangesAsync();
return tokens;
}
public async Task LogoutAsync(Guid sessionId, Guid userId)
{
var session = await db.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId);
if (session != null)
{
session.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
public async Task<List<SessionResponse>> GetSessionsAsync(Guid userId)
{
var sessions = await db.UserSessions
.Where(s => s.UserId == userId && s.RevokedAt == null && s.ExpiresAt > DateTime.UtcNow)
.OrderByDescending(s => s.LastUsedAt ?? s.IssuedAt)
.ToListAsync();
return sessions.Select(s => new SessionResponse(
s.Id, s.DeviceName, s.UserAgent, s.IpAddress,
s.IssuedAt, s.LastUsedAt, false
)).ToList();
}
public async Task RevokeSessionAsync(Guid sessionId, Guid userId)
{
var session = await db.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId)
?? throw new KeyNotFoundException("Session not found");
session.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<bool> VerifyEmailAsync(string tokenHash, string code)
{
var confirmation = await db.ConfirmationTokens
.FirstOrDefaultAsync(t =>
t.TokenHash == tokenHash &&
t.Purpose == TokenPurpose.EmailVerification &&
!t.IsConsumed &&
t.ExpiresAt > DateTime.UtcNow)
?? throw new InvalidOperationException("Invalid or expired token");
if (confirmation.TryCount >= confirmation.MaxTries)
throw new InvalidOperationException("Too many attempts");
confirmation.TryCount++;
if (confirmation.Code != code)
{
await db.SaveChangesAsync();
return false;
}
confirmation.IsConsumed = true;
confirmation.ConsumedAt = DateTime.UtcNow;
if (confirmation.UserId.HasValue)
{
var user = await db.Users.FindAsync(confirmation.UserId.Value);
if (user != null)
{
user.EmailVerified = true;
user.EmailVerifiedAt = DateTime.UtcNow;
}
}
await db.SaveChangesAsync();
return true;
}
public async Task<bool> VerifyPhoneAsync(string tokenHash, string code)
{
var confirmation = await db.ConfirmationTokens
.FirstOrDefaultAsync(t =>
t.TokenHash == tokenHash &&
t.Purpose == TokenPurpose.PhoneVerification &&
!t.IsConsumed &&
t.ExpiresAt > DateTime.UtcNow)
?? throw new InvalidOperationException("Invalid or expired token");
if (confirmation.TryCount >= confirmation.MaxTries)
throw new InvalidOperationException("Too many attempts");
confirmation.TryCount++;
if (confirmation.Code != code)
{
await db.SaveChangesAsync();
return false;
}
confirmation.IsConsumed = true;
confirmation.ConsumedAt = DateTime.UtcNow;
if (confirmation.UserId.HasValue)
{
var user = await db.Users.FindAsync(confirmation.UserId.Value);
if (user != null)
{
user.PhoneVerified = true;
user.PhoneVerifiedAt = DateTime.UtcNow;
}
}
await db.SaveChangesAsync();
return true;
}
public async Task RequestPasswordResetAsync(string tenantSlug, string? email, string? phone)
{
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == tenantSlug && t.DeletedAt == null);
if (tenant == null) return;
User? user = null;
if (!string.IsNullOrEmpty(email))
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.Email == email && u.DeletedAt == null);
else if (!string.IsNullOrEmpty(phone))
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.PhoneNumber == phone && u.DeletedAt == null);
if (user == null) return;
await CreateConfirmationTokenAsync(user.Id, tenant.Id, TokenPurpose.PasswordReset, email ?? phone!, null);
}
public async Task<bool> ConfirmPasswordResetAsync(string token, string newPassword)
{
var tokenHash = tokenService.HashToken(token);
var confirmation = await db.ConfirmationTokens
.FirstOrDefaultAsync(t =>
t.TokenHash == tokenHash &&
t.Purpose == TokenPurpose.PasswordReset &&
!t.IsConsumed &&
t.ExpiresAt > DateTime.UtcNow)
?? throw new InvalidOperationException("Invalid or expired token");
confirmation.IsConsumed = true;
confirmation.ConsumedAt = DateTime.UtcNow;
if (confirmation.UserId.HasValue)
{
var user = await db.Users.FindAsync(confirmation.UserId.Value);
if (user != null)
{
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
user.PasswordSetAt = DateTime.UtcNow;
user.LastPasswordResetDate = DateTime.UtcNow;
// Revoke all sessions
await db.UserSessions
.Where(s => s.UserId == user.Id && s.RevokedAt == null)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RevokedAt, DateTime.UtcNow));
}
}
await db.SaveChangesAsync();
return true;
}
public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword)
{
var user = await db.Users.FindAsync(userId)
?? throw new KeyNotFoundException("User not found");
if (string.IsNullOrEmpty(user.PasswordHash) || !BCrypt.Net.BCrypt.Verify(currentPassword, user.PasswordHash))
throw new UnauthorizedAccessException("Current password is incorrect");
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
user.PasswordSetAt = DateTime.UtcNow;
user.LastPasswordResetDate = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<MfaSetupResponse> SetupMfaAsync(Guid userId, string factorType, string? label)
{
if (!Enum.TryParse<Domain.Enums.MfaFactorType>(factorType, true, out var type))
throw new ArgumentException("Invalid factor type");
if (type == Domain.Enums.MfaFactorType.TOTP)
{
var secret = KeyGeneration.GenerateRandomKey(20);
var base32Secret = Base32Encoding.ToString(secret);
var user = await db.Users.FindAsync(userId)!;
var factor = new MfaFactor
{
UserId = userId,
FactorType = type,
SecretEncrypted = base32Secret,
Label = label ?? user?.Email ?? "FlatRender",
};
db.MfaFactors.Add(factor);
await db.SaveChangesAsync();
var qrLabel = Uri.EscapeDataString($"FlatRender:{user?.Email ?? userId.ToString()}");
var qrSecret = Uri.EscapeDataString(base32Secret);
var qrUrl = $"otpauth://totp/{qrLabel}?secret={qrSecret}&issuer=FlatRender";
return new MfaSetupResponse(factor.Id, base32Secret, qrUrl, null);
}
throw new NotImplementedException($"Factor type {factorType} not yet supported");
}
public async Task<bool> VerifyMfaAsync(Guid userId, Guid factorId, string code)
{
var factor = await db.MfaFactors.FirstOrDefaultAsync(m => m.Id == factorId && m.UserId == userId)
?? throw new KeyNotFoundException("MFA factor not found");
if (!VerifyTotp(factor.SecretEncrypted!, code)) return false;
factor.IsVerified = true;
factor.IsPrimary = true;
factor.LastUsedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return true;
}
public async Task<AuthTokensResponse> ChallengeMfaAsync(string mfaToken, string code)
{
var (userId, tenantId) = ValidateMfaToken(mfaToken);
var factor = await db.MfaFactors.FirstOrDefaultAsync(m => m.UserId == userId && m.IsVerified && m.IsPrimary)
?? throw new UnauthorizedAccessException("No verified MFA factor");
if (!VerifyTotp(factor.SecretEncrypted!, code))
throw new UnauthorizedAccessException("Invalid MFA code");
factor.LastUsedAt = DateTime.UtcNow;
var user = await db.Users.FindAsync(userId)!
?? throw new UnauthorizedAccessException("User not found");
var tenant = await db.Tenants.FindAsync(tenantId)!
?? throw new UnauthorizedAccessException("Tenant not found");
return await CreateSessionAsync(user, tenant, null, null, null);
}
public async Task SubscribePushAsync(Guid userId, Guid tenantId, string endpoint, string p256dh, string auth, string? userAgent)
{
var existing = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.UserId == userId && p.Endpoint == endpoint);
if (existing != null)
{
existing.IsActive = true;
existing.FailureCount = 0;
}
else
{
db.PushSubscriptions.Add(new PushSubscription
{
UserId = userId,
TenantId = tenantId,
Endpoint = endpoint,
P256dhKey = p256dh,
AuthKey = auth,
UserAgent = userAgent,
});
}
await db.SaveChangesAsync();
}
public async Task UnsubscribePushAsync(Guid userId, string? endpoint)
{
if (string.IsNullOrEmpty(endpoint))
{
await db.PushSubscriptions
.Where(p => p.UserId == userId)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.IsActive, false));
}
else
{
await db.PushSubscriptions
.Where(p => p.UserId == userId && p.Endpoint == endpoint)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.IsActive, false));
}
}
// ── Private helpers ──────────────────────────────────────────────────
private async Task<AuthTokensResponse> CreateSessionAsync(
User user, Tenant tenant,
string? deviceId, string? deviceName, string? ipAddress,
Guid? replacingSessionId = null)
{
var accessToken = tokenService.GenerateAccessToken(user, tenant);
var refreshToken = tokenService.GenerateRefreshToken();
var refreshHash = tokenService.HashToken(refreshToken);
var session = new UserSession
{
UserId = user.Id,
TenantId = tenant.Id,
RefreshTokenHash = refreshHash,
DeviceId = deviceId,
DeviceName = deviceName,
IpAddress = ipAddress,
ExpiresAt = DateTime.UtcNow.AddDays(_refreshTokenDays),
};
db.UserSessions.Add(session);
await db.SaveChangesAsync();
var userResponse = MapUserResponse(user);
var tenantResponse = MapTenantResponse(tenant);
return new AuthTokensResponse(accessToken, refreshToken, "Bearer", 15 * 60, userResponse, tenantResponse);
}
private async Task CreateConfirmationTokenAsync(
Guid userId, Guid tenantId, TokenPurpose purpose, string identifier, string? ipAddress)
{
var rawToken = tokenService.GenerateRefreshToken();
var tokenHash = tokenService.HashToken(rawToken);
var code = Random.Shared.Next(100000, 999999).ToString();
db.ConfirmationTokens.Add(new ConfirmationToken
{
UserId = userId,
TenantId = tenantId,
Purpose = purpose,
Identifier = identifier,
TokenHash = tokenHash,
Code = code,
RequestIp = ipAddress,
ExpiresAt = DateTime.UtcNow.AddHours(24),
});
await db.SaveChangesAsync();
// TODO: send code via email/SMS
}
private string GenerateMfaToken(Guid userId, Guid tenantId)
{
// Short-lived token encoding userId+tenantId for the MFA challenge flow
var payload = $"{userId}:{tenantId}:{DateTime.UtcNow.AddMinutes(10).Ticks}";
var bytes = System.Text.Encoding.UTF8.GetBytes(payload);
return Convert.ToBase64String(bytes);
}
private (Guid userId, Guid tenantId) ValidateMfaToken(string mfaToken)
{
try
{
var payload = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(mfaToken));
var parts = payload.Split(':');
if (parts.Length != 3) throw new UnauthorizedAccessException("Invalid MFA token");
var expiry = new DateTime(long.Parse(parts[2]));
if (expiry < DateTime.UtcNow) throw new UnauthorizedAccessException("MFA token expired");
return (Guid.Parse(parts[0]), Guid.Parse(parts[1]));
}
catch (Exception ex) when (ex is not UnauthorizedAccessException)
{
throw new UnauthorizedAccessException("Invalid MFA token");
}
}
private static bool VerifyTotp(string base32Secret, string code)
{
try
{
var secretBytes = Base32Encoding.ToBytes(base32Secret);
var totp = new Totp(secretBytes);
return totp.VerifyTotp(code, out _, new VerificationWindow(1, 1));
}
catch
{
return false;
}
}
internal static UserResponse MapUserResponse(User u) => new(
u.Id, u.TenantId, u.Email, u.EmailVerified,
u.PhoneNumber, u.PhoneVerified, u.FullName, u.AvatarUrl,
u.IsAdmin, u.IsTenantAdmin, u.RegisterMode.ToString(),
u.LastActiveDate, u.BalanceMinor, u.AffiliateBalanceMinor,
u.LoyaltyScore, u.DailyRemainRenderCount, u.MaxDailyRenderCount,
u.ParallelRenderingCeiling, u.UsedStorageBytes, u.RegisterDate
);
internal static TenantResponse MapTenantResponse(Tenant t) => new(
t.Id, t.Slug, t.Name, t.Kind.ToString(), t.Status.ToString(),
t.CustomDomain, t.DomainVerified, t.ContactEmail,
t.MaxUsers, t.MaxStorageGb, t.MonthlyRenderQty,
t.TrialEndsAt, t.CreatedAt
);
}
public class MfaRequiredException(string mfaToken, string message) : Exception(message)
{
public string MfaToken { get; } = mfaToken;
}
@@ -0,0 +1,92 @@
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Application.Services;
public class DiscountService(IdentityDbContext db) : IDiscountService
{
public async Task<DiscountValidateResponse> ValidateAsync(Guid tenantId, string code, Guid? planId)
{
var discount = await db.Discounts.FirstOrDefaultAsync(d =>
d.TenantId == tenantId && d.Code == code && d.IsActive &&
(d.StartsAt == null || d.StartsAt <= DateTime.UtcNow) &&
(d.ExpiresAt == null || d.ExpiresAt >= DateTime.UtcNow) &&
(d.MaxUseCount == null || d.UsedCount < d.MaxUseCount));
if (discount == null)
return new DiscountValidateResponse(false, 0, "Unknown", 0);
if (planId.HasValue && discount.AppliesToPlanIds != null && discount.AppliesToPlanIds.Length > 0)
{
if (!discount.AppliesToPlanIds.Contains(planId.Value))
return new DiscountValidateResponse(false, 0, discount.Kind.ToString(), discount.Value);
}
long discountMinor = 0;
if (planId.HasValue)
{
var plan = await db.Plans.FindAsync(planId.Value);
if (plan != null)
{
discountMinor = discount.Kind == DiscountKind.Percentage
? (long)(plan.PriceMinor * (double)discount.Value / 100)
: (long)discount.Value;
}
}
return new DiscountValidateResponse(true, discountMinor, discount.Kind.ToString(), discount.Value);
}
public async Task<PagedResponse<DiscountResponse>> ListAsync(Guid tenantId, int page, int pageSize)
{
var total = await db.Discounts.LongCountAsync(d => d.TenantId == tenantId);
var discounts = await db.Discounts
.Where(d => d.TenantId == tenantId)
.OrderByDescending(d => d.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResponse<DiscountResponse>(
discounts.Select(MapResponse).ToList(),
new PaginationMeta(page, pageSize, total, total > (long)page * pageSize)
);
}
public async Task<DiscountResponse> CreateAsync(Guid tenantId, CreateDiscountRequest request)
{
var exists = await db.Discounts.AnyAsync(d => d.TenantId == tenantId && d.Code == request.Code);
if (exists) throw new InvalidOperationException("Discount code already exists");
if (!Enum.TryParse<DiscountKind>(request.Kind, true, out var kind))
throw new ArgumentException("Invalid discount kind");
var discount = new Discount
{
TenantId = tenantId,
Name = request.Name,
Code = request.Code.ToUpper(),
Kind = kind,
Value = request.Value,
OwnerUserId = request.OwnerUserId,
OwnerProfitPercentage = request.OwnerProfitPercentage,
MaxUseCount = request.MaxUseCount,
AppliesToPlanIds = request.AppliesToPlanIds,
StartsAt = request.StartsAt,
ExpiresAt = request.ExpiresAt,
};
db.Discounts.Add(discount);
await db.SaveChangesAsync();
return MapResponse(discount);
}
private static DiscountResponse MapResponse(Discount d) => new(
d.Id, d.Name, d.Code, d.Kind.ToString(), d.Value,
d.UsedCount, d.MaxUseCount, d.IsActive, d.ExpiresAt, d.CreatedAt
);
}
@@ -0,0 +1,151 @@
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Application.Services;
public class GamificationService(IdentityDbContext db) : IGamificationService
{
public async Task<List<QuestResponse>> GetActiveQuestsAsync(Guid userId, Guid tenantId)
{
var quests = await db.Quests
.Where(q => q.IsActive &&
(q.TenantId == null || q.TenantId == tenantId) &&
(q.StartsAt == null || q.StartsAt <= DateTime.UtcNow) &&
(q.ExpiresAt == null || q.ExpiresAt > DateTime.UtcNow))
.OrderBy(q => q.OrderValue)
.ToListAsync();
var progressMap = await db.UserQuestProgresses
.Where(p => p.UserId == userId && quests.Select(q => q.Id).Contains(p.QuestId))
.ToDictionaryAsync(p => p.QuestId, p => p);
return quests.Select(q =>
{
progressMap.TryGetValue(q.Id, out var progress);
return new QuestResponse(
q.Id, q.Title, q.Challenge, q.Why, q.Hint, q.Icon,
q.QuestType.ToString(), q.TargetCount,
progress?.CurrentCount ?? 0,
progress?.IsCompleted ?? false,
progress?.PrizeClaimed ?? false,
q.PrizeType.ToString(), q.PrizeAmount, q.ExpiresAt
);
}).ToList();
}
public async Task ClaimQuestPrizeAsync(Guid userId, Guid questId)
{
var progress = await db.UserQuestProgresses
.FirstOrDefaultAsync(p => p.UserId == userId && p.QuestId == questId && p.IsCompleted && !p.PrizeClaimed)
?? throw new InvalidOperationException("Quest not completed or prize already claimed");
var quest = await db.Quests.FindAsync(questId)
?? throw new KeyNotFoundException("Quest not found");
await ApplyPrizeAsync(userId, quest.PrizeType, quest.PrizeAmount);
progress.PrizeClaimed = true;
progress.PrizeClaimedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<List<EarnedGiftResponse>> GetEarnedGiftsAsync(Guid userId)
{
var gifts = await db.EarnedGifts
.Include(eg => eg.Gift)
.Where(eg => eg.UserId == userId && !eg.IsUsed && (eg.ExpiresAt == null || eg.ExpiresAt > DateTime.UtcNow))
.OrderByDescending(eg => eg.EarnedAt)
.ToListAsync();
return gifts.Select(eg => new EarnedGiftResponse(
eg.Id, eg.GiftId, eg.Gift.Name, eg.Gift.Description,
eg.Gift.PrizeType.ToString(), eg.Gift.Value, eg.Gift.Unit,
eg.EarnedAt, eg.ExpiresAt, eg.IsUsed
)).ToList();
}
public async Task UseEarnedGiftAsync(Guid userId, Guid earnedGiftId)
{
var earned = await db.EarnedGifts
.Include(eg => eg.Gift)
.FirstOrDefaultAsync(eg => eg.Id == earnedGiftId && eg.UserId == userId && !eg.IsUsed &&
(eg.ExpiresAt == null || eg.ExpiresAt > DateTime.UtcNow))
?? throw new InvalidOperationException("Earned gift not found or already used");
await ApplyPrizeAsync(userId, earned.Gift.PrizeType, earned.Gift.Value);
earned.IsUsed = true;
earned.UsedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task IncrementQuestProgressAsync(Guid userId, Guid tenantId, string targetEvent)
{
var matchingQuests = await db.Quests
.Where(q => q.IsActive && q.TargetEvent == targetEvent &&
(q.TenantId == null || q.TenantId == tenantId) &&
(q.ExpiresAt == null || q.ExpiresAt > DateTime.UtcNow))
.ToListAsync();
foreach (var quest in matchingQuests)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
DateOnly? periodStart = quest.QuestType switch
{
QuestType.Daily => today,
QuestType.Weekly => today.AddDays(-(int)DateTime.UtcNow.DayOfWeek),
_ => null
};
var progress = await db.UserQuestProgresses
.FirstOrDefaultAsync(p => p.UserId == userId && p.QuestId == quest.Id && p.PeriodStart == periodStart);
if (progress == null)
{
progress = new UserQuestProgress
{
UserId = userId,
QuestId = quest.Id,
PeriodStart = periodStart,
};
db.UserQuestProgresses.Add(progress);
}
if (progress.IsCompleted) continue;
progress.CurrentCount++;
if (progress.CurrentCount >= quest.TargetCount)
{
progress.IsCompleted = true;
progress.CompletedAt = DateTime.UtcNow;
}
}
await db.SaveChangesAsync();
}
private async Task ApplyPrizeAsync(Guid userId, PrizeType prizeType, long amount)
{
var user = await db.Users.FindAsync(userId) ?? throw new KeyNotFoundException("User not found");
switch (prizeType)
{
case PrizeType.Balance:
user.BalanceMinor += amount;
break;
case PrizeType.RenderSeconds:
user.UserDailyFreeChargeSec += (int)amount;
break;
case PrizeType.LoyaltyPoints:
user.LoyaltyScore += (int)amount;
break;
// StorageGB, Plan, Discount require more complex handling (not inline)
}
user.UpdatedAt = DateTime.UtcNow;
}
}
@@ -0,0 +1,24 @@
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
namespace FlatRender.IdentitySvc.Application.Services.Interfaces;
public interface IAuthService
{
Task<RegisterResponse> RegisterAsync(RegisterRequest request, string? ipAddress);
Task<AuthTokensResponse> LoginAsync(LoginRequest request, string? ipAddress);
Task<AuthTokensResponse> RefreshAsync(string refreshToken);
Task LogoutAsync(Guid sessionId, Guid userId);
Task<List<SessionResponse>> GetSessionsAsync(Guid userId);
Task RevokeSessionAsync(Guid sessionId, Guid userId);
Task<bool> VerifyEmailAsync(string tokenHash, string code);
Task<bool> VerifyPhoneAsync(string tokenHash, string code);
Task RequestPasswordResetAsync(string tenantSlug, string? email, string? phone);
Task<bool> ConfirmPasswordResetAsync(string token, string newPassword);
Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword);
Task<MfaSetupResponse> SetupMfaAsync(Guid userId, string factorType, string? label);
Task<bool> VerifyMfaAsync(Guid userId, Guid factorId, string code);
Task<AuthTokensResponse> ChallengeMfaAsync(string mfaToken, string code);
Task SubscribePushAsync(Guid userId, Guid tenantId, string endpoint, string p256dh, string auth, string? userAgent);
Task UnsubscribePushAsync(Guid userId, string? endpoint);
}
@@ -0,0 +1,11 @@
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
namespace FlatRender.IdentitySvc.Application.Services.Interfaces;
public interface IDiscountService
{
Task<DiscountValidateResponse> ValidateAsync(Guid tenantId, string code, Guid? planId);
Task<PagedResponse<DiscountResponse>> ListAsync(Guid tenantId, int page, int pageSize);
Task<DiscountResponse> CreateAsync(Guid tenantId, CreateDiscountRequest request);
}
@@ -0,0 +1,12 @@
using FlatRender.IdentitySvc.Models.Responses;
namespace FlatRender.IdentitySvc.Application.Services.Interfaces;
public interface IGamificationService
{
Task<List<QuestResponse>> GetActiveQuestsAsync(Guid userId, Guid tenantId);
Task ClaimQuestPrizeAsync(Guid userId, Guid questId);
Task<List<EarnedGiftResponse>> GetEarnedGiftsAsync(Guid userId);
Task UseEarnedGiftAsync(Guid userId, Guid earnedGiftId);
Task IncrementQuestProgressAsync(Guid userId, Guid tenantId, string targetEvent);
}
@@ -0,0 +1,33 @@
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
namespace FlatRender.IdentitySvc.Application.Services.Interfaces;
public interface IPaymentService
{
Task<PagedResponse<PaymentResponse>> GetUserPaymentsAsync(Guid userId, int page, int pageSize);
Task<PaymentResponse> GetByIdAsync(Guid paymentId, Guid userId);
// ── ZarinPal ────────────────────────────────────────────────────────────────
/// <summary>Calls ZarinPal request API and returns the zarinpal.com redirect URL.</summary>
Task<string> InitiateZarinPalAsync(Guid paymentId, Guid userId);
Task<string> HandleZarinPalCallbackAsync(string authority, string status);
// ── SnapPay ──────────────────────────────────────────────────────────────────
/// <summary>Calls SnapPay token API and returns the snappay.ir redirect URL.</summary>
Task<string> InitiateSnapPayAsync(Guid paymentId, Guid userId);
/// <summary>Handles SnapPay callback query params (paymentToken, shapSnapStatus).</summary>
Task<string> HandleSnapPayCallbackAsync(string paymentToken, string shapStatus);
// ── Tara ─────────────────────────────────────────────────────────────────────
/// <summary>Calls Tara request API and returns the tara.ir redirect URL.</summary>
Task<string> InitiateTaraAsync(Guid paymentId, Guid userId);
/// <summary>Handles Tara callback query params (token, status).</summary>
Task<string> HandleTaraCallbackAsync(string token, string status);
// ── Stripe ───────────────────────────────────────────────────────────────────
Task HandleStripeWebhookAsync(string payload, string signature);
// ── Refunds ───────────────────────────────────────────────────────────────────
Task<RefundResponse> IssueRefundAsync(Guid paymentId, long? amountMinor, string reason, string refundTo);
}
@@ -0,0 +1,12 @@
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
namespace FlatRender.IdentitySvc.Application.Services.Interfaces;
public interface IPlanService
{
Task<List<PlanResponse>> ListAsync(Guid tenantId, string? scope);
Task<PlanResponse> GetByIdAsync(Guid planId);
Task<UserPlanResponse?> GetCurrentPlanAsync(Guid userId);
Task<PurchasePlanResponse> PurchasePlanAsync(Guid userId, Guid tenantId, PurchasePlanRequest request);
}
@@ -0,0 +1,30 @@
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
namespace FlatRender.IdentitySvc.Application.Services.Interfaces;
public interface ITenantService
{
Task<PagedResponse<TenantResponse>> ListAsync(int page, int pageSize);
Task<TenantResponse> CreateAsync(CreateTenantRequest request);
Task<TenantResponse> GetByIdAsync(Guid tenantId);
Task<TenantResponse> GetBySlugAsync(string slug);
Task<TenantResponse> UpdateAsync(Guid tenantId, UpdateTenantRequest request);
Task<TenantBrandingResponse> GetBrandingAsync(Guid tenantId);
Task<TenantBrandingResponse> UpsertBrandingAsync(Guid tenantId, TenantBrandingRequest request);
Task<DomainVerificationResponse> StartDomainVerificationAsync(Guid tenantId, string domain, string method);
Task<List<TenantUsageDayResponse>> GetUsageAsync(Guid tenantId, DateOnly from, DateOnly to);
// API Keys
Task<List<ApiKeyResponse>> GetApiKeysAsync(Guid tenantId);
Task<ApiKeyCreatedResponse> CreateApiKeyAsync(Guid tenantId, Guid createdByUserId, CreateApiKeyRequest request);
Task RevokeApiKeyAsync(Guid tenantId, Guid apiKeyId, string? reason);
Task<ApiKeyValidateResponse> ValidateApiKeyAsync(string keyPrefix, string keyHash, string? ipAddress);
// Webhooks
Task<List<WebhookResponse>> GetWebhooksAsync(Guid tenantId);
Task<WebhookResponse> CreateWebhookAsync(Guid tenantId, CreateWebhookRequest request);
Task DeleteWebhookAsync(Guid tenantId, Guid webhookId);
Task<List<WebhookDeliveryResponse>> GetWebhookDeliveriesAsync(Guid tenantId, Guid webhookId);
}
@@ -0,0 +1,12 @@
using FlatRender.IdentitySvc.Domain.Entities;
namespace FlatRender.IdentitySvc.Application.Services.Interfaces;
public interface ITokenService
{
string GenerateAccessToken(User user, Tenant tenant);
string GenerateRefreshToken();
string HashToken(string token);
(Guid userId, Guid tenantId, bool isAdmin) ValidateAccessToken(string token);
string GenerateServiceToken();
}
@@ -0,0 +1,17 @@
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
namespace FlatRender.IdentitySvc.Application.Services.Interfaces;
public interface IUserService
{
Task<UserResponse> GetMeAsync(Guid userId);
Task<UserResponse> UpdateMeAsync(Guid userId, UpdateUserRequest request);
Task<BalanceResponse> GetBalanceAsync(Guid userId);
Task UpdateAvatarAsync(Guid userId, Guid? avatarId, string? avatarUrl);
Task<UserResponse> GetByIdAsync(Guid userId);
Task<PagedResponse<UserResponse>> SearchAsync(string? q, Guid? tenantId, int page, int pageSize);
Task BanAsync(Guid userId, string reason, DateTime? unblockDate);
Task UnbanAsync(Guid userId);
}
@@ -0,0 +1,506 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Application.Services;
public class PaymentService(
IdentityDbContext db,
IHttpClientFactory httpClientFactory,
IConfiguration config) : IPaymentService
{
// ── Config ────────────────────────────────────────────────────────────────────
private string ZarinPalMerchantId => config["ZarinPal:MerchantId"] ?? "";
private string ZarinPalCallbackUrl => config["ZarinPal:CallbackUrl"] ?? "http://localhost:8080/v1/payments/callback/zarinpal";
private bool ZarinPalSandbox => config["ZarinPal:Sandbox"] == "true";
private string SnapPayClientId => config["SnapPay:ClientId"] ?? "";
private string SnapPayClientSecret => config["SnapPay:ClientSecret"] ?? "";
private string SnapPayBaseUrl => config["SnapPay:BaseUrl"] ?? "https://api.snappay.ir";
private string SnapPayCallbackUrl => config["SnapPay:CallbackUrl"] ?? "http://localhost:8080/v1/payments/callback/snappay";
// Tara payment gateway (tara.ir) — API key auth
// Docs: https://www.tara.ir/documents/payment-gateway
private string TaraApiKey => config["Tara:ApiKey"] ?? "";
private string TaraBaseUrl => config["Tara:BaseUrl"] ?? "https://api.tara.ir";
private string TaraCallbackUrl => config["Tara:CallbackUrl"] ?? "http://localhost:8080/v1/payments/callback/tara";
private string StripeSecretKey => config["Stripe:SecretKey"] ?? "";
private string StripeWebhookSecret => config["Stripe:WebhookSecret"] ?? "";
private const long IrrToToman = 10; // 1 Toman = 10 Rials
// ── Queries ───────────────────────────────────────────────────────────────────
public async Task<PagedResponse<PaymentResponse>> GetUserPaymentsAsync(Guid userId, int page, int pageSize)
{
var total = await db.Payments.CountAsync(p => p.UserId == userId);
var payments = await db.Payments
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResponse<PaymentResponse>(
payments.Select(MapPaymentResponse).ToList(),
new PaginationMeta(page, pageSize, total, (long)(page * pageSize) < total)
);
}
public async Task<PaymentResponse> GetByIdAsync(Guid paymentId, Guid userId)
{
var payment = await db.Payments
.FirstOrDefaultAsync(p => p.Id == paymentId && p.UserId == userId)
?? throw new KeyNotFoundException("Payment not found");
return MapPaymentResponse(payment);
}
// ── ZarinPal initiation ───────────────────────────────────────────────────────
public async Task<string> InitiateZarinPalAsync(Guid paymentId, Guid userId)
{
var payment = await db.Payments.FirstOrDefaultAsync(
p => p.Id == paymentId && p.UserId == userId && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment not found or already processed");
if (string.IsNullOrEmpty(ZarinPalMerchantId))
throw new InvalidOperationException("ZarinPal:MerchantId is not configured");
var amountToman = payment.AmountMinor / IrrToToman;
var baseUrl = ZarinPalSandbox
? "https://sandbox.zarinpal.com/pg/v4/payment"
: "https://api.zarinpal.com/pg/v4/payment";
var http = httpClientFactory.CreateClient("zarinpal");
var reqBody = new
{
merchant_id = ZarinPalMerchantId,
amount = amountToman,
callback_url = ZarinPalCallbackUrl,
description = payment.Title ?? "پرداخت فلت‌رندر",
metadata = new { order_id = paymentId.ToString() },
};
var resp = await http.PostAsJsonAsync($"{baseUrl}/request.json", reqBody);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
var code = root.GetProperty("data").GetProperty("code").GetInt32();
if (code != 100)
{
var errDetail = root.TryGetProperty("errors", out var errEl) ? errEl.ToString() : "no details";
throw new InvalidOperationException($"ZarinPal request failed (code={code}): {errDetail}");
}
var authority = root.GetProperty("data").GetProperty("authority").GetString()!;
payment.GatewayToken = authority;
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var startPage = ZarinPalSandbox
? "https://sandbox.zarinpal.com/pg/StartPay/"
: "https://www.zarinpal.com/pg/StartPay/";
return $"{startPage}{authority}";
}
// ── ZarinPal callback (browser returns after payment) ─────────────────────────
public async Task<string> HandleZarinPalCallbackAsync(string authority, string status)
{
if (status != "OK")
return "/payment/result?status=failed&gateway=zarinpal";
var payment = await db.Payments.Include(p => p.User)
.FirstOrDefaultAsync(p => p.GatewayToken == authority && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment record not found for this authority");
var amountToman = payment.AmountMinor / IrrToToman;
var baseUrl = ZarinPalSandbox
? "https://sandbox.zarinpal.com/pg/v4/payment"
: "https://api.zarinpal.com/pg/v4/payment";
var http = httpClientFactory.CreateClient("zarinpal");
var verifyBody = new
{
merchant_id = ZarinPalMerchantId,
amount = amountToman,
authority,
};
var resp = await http.PostAsJsonAsync($"{baseUrl}/verify.json", verifyBody);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
var code = root.GetProperty("data").GetProperty("code").GetInt32();
if (code is 100 or 101) // 101 = already verified (idempotent)
{
var refId = root.GetProperty("data").GetProperty("ref_id").GetInt64().ToString();
payment.Status = PaymentStatus.Succeeded;
payment.GatewayTrackId = refId;
payment.ConfirmedAt = DateTime.UtcNow;
payment.UpdatedAt = DateTime.UtcNow;
if (payment.PlanId.HasValue)
await ActivatePlanAsync(payment);
await db.SaveChangesAsync();
return $"/payment/result?status=success&ref={refId}";
}
payment.Status = PaymentStatus.Failed;
payment.FailedAt = DateTime.UtcNow;
payment.FailureReason = $"ZarinPal verify code: {code}";
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return "/payment/result?status=failed&gateway=zarinpal";
}
// ── SnapPay initiation ────────────────────────────────────────────────────────
// API ref: https://developer.snappay.ir/
// Flow: POST /api/v1/payment/token → get paymentToken → redirect to snappay.ir/payment/{token}
// Callback query params: paymentToken, merchantOrderId, shapSnapStatus (DONE|FAIL|CANCEL)
// Verify: POST /api/v1/payment/verify
public async Task<string> InitiateSnapPayAsync(Guid paymentId, Guid userId)
{
var payment = await db.Payments.FirstOrDefaultAsync(
p => p.Id == paymentId && p.UserId == userId && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment not found or already processed");
if (string.IsNullOrEmpty(SnapPayClientId) || string.IsNullOrEmpty(SnapPayClientSecret))
throw new InvalidOperationException("SnapPay:ClientId / SnapPay:ClientSecret are not configured");
var amountToman = payment.AmountMinor / IrrToToman;
var http = httpClientFactory.CreateClient("snappay");
var reqBody = new
{
clientId = SnapPayClientId,
clientSecret = SnapPayClientSecret,
amount = amountToman,
currency = "TOMAN",
callbackUrl = SnapPayCallbackUrl,
description = payment.Title ?? "پرداخت فلت‌رندر",
merchantOrderId = paymentId.ToString(),
};
var resp = await http.PostAsJsonAsync($"{SnapPayBaseUrl}/api/v1/payment/token", reqBody);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
if (!root.TryGetProperty("status", out var statusEl) || !statusEl.GetBoolean())
{
var error = root.TryGetProperty("error", out var e) ? e.ToString() : "SnapPay error";
throw new InvalidOperationException($"SnapPay payment request failed: {error}");
}
var paymentToken = root.GetProperty("response").GetProperty("paymentToken").GetString()!;
payment.GatewayToken = paymentToken;
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return $"https://snappay.ir/payment/{paymentToken}";
}
public async Task<string> HandleSnapPayCallbackAsync(string paymentToken, string shapStatus)
{
// shapSnapStatus values: DONE = success, FAIL / CANCEL = failure
if (!shapStatus.Equals("DONE", StringComparison.OrdinalIgnoreCase))
return "/payment/result?status=failed&gateway=snappay";
var payment = await db.Payments.Include(p => p.User)
.FirstOrDefaultAsync(p => p.GatewayToken == paymentToken && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment record not found for this token");
var http = httpClientFactory.CreateClient("snappay");
var verifyBody = new
{
clientId = SnapPayClientId,
clientSecret = SnapPayClientSecret,
paymentToken,
};
var resp = await http.PostAsJsonAsync($"{SnapPayBaseUrl}/api/v1/payment/verify", verifyBody);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
if (root.TryGetProperty("status", out var okEl) && okEl.GetBoolean())
{
var responseObj = root.GetProperty("response");
var transactionId = responseObj.TryGetProperty("transactionId", out var tid)
? tid.ToString() : paymentToken;
payment.Status = PaymentStatus.Succeeded;
payment.GatewayTrackId = transactionId;
payment.ConfirmedAt = DateTime.UtcNow;
payment.UpdatedAt = DateTime.UtcNow;
if (payment.PlanId.HasValue)
await ActivatePlanAsync(payment);
await db.SaveChangesAsync();
return $"/payment/result?status=success&ref={transactionId}";
}
payment.Status = PaymentStatus.Failed;
payment.FailedAt = DateTime.UtcNow;
payment.FailureReason = root.TryGetProperty("error", out var err)
? err.ToString() : "SnapPay verification failed";
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return "/payment/result?status=failed&gateway=snappay";
}
// ── Tara initiation ────────────────────────────────────────────────────────────
// API ref: https://www.tara.ir/documents/payment-gateway
// Auth: X-Api-Key request header
// Flow: POST /v1/payment/request → { token, redirectUrl }
// → redirect browser to redirectUrl (or fallback: {baseUrl}/payment/{token})
// Callback query params: token, status (OK|FAILED)
// Verify: POST /v1/payment/verify body: { token, orderId }
public async Task<string> InitiateTaraAsync(Guid paymentId, Guid userId)
{
var payment = await db.Payments.FirstOrDefaultAsync(
p => p.Id == paymentId && p.UserId == userId && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment not found or already processed");
if (string.IsNullOrEmpty(TaraApiKey))
throw new InvalidOperationException("Tara:ApiKey is not configured");
var amountToman = payment.AmountMinor / IrrToToman;
var http = httpClientFactory.CreateClient("tara");
using var request = new HttpRequestMessage(HttpMethod.Post, $"{TaraBaseUrl}/v1/payment/request");
request.Headers.Add("X-Api-Key", TaraApiKey);
request.Content = JsonContent.Create(new
{
amount = amountToman,
orderId = paymentId.ToString(),
callbackUrl = TaraCallbackUrl,
description = payment.Title ?? "پرداخت فلت‌رندر",
});
var resp = await http.SendAsync(request);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
if (!root.TryGetProperty("token", out var tokenEl))
{
var msg = root.TryGetProperty("message", out var m) ? m.GetString() : "Tara error";
throw new InvalidOperationException($"Tara payment request failed: {msg}");
}
var token = tokenEl.GetString()!;
payment.GatewayToken = token;
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// Use the redirectUrl Tara returns; fall back to constructed URL if absent
if (root.TryGetProperty("redirectUrl", out var urlEl) && !string.IsNullOrEmpty(urlEl.GetString()))
return urlEl.GetString()!;
return $"{TaraBaseUrl}/payment/{token}";
}
public async Task<string> HandleTaraCallbackAsync(string token, string status)
{
var ok = status.Equals("OK", StringComparison.OrdinalIgnoreCase)
|| status.Equals("SUCCESS", StringComparison.OrdinalIgnoreCase);
if (!ok)
return "/payment/result?status=failed&gateway=tara";
var payment = await db.Payments.Include(p => p.User)
.FirstOrDefaultAsync(p => p.GatewayToken == token && p.Status == PaymentStatus.Pending)
?? throw new KeyNotFoundException("Payment record not found for this token");
var http = httpClientFactory.CreateClient("tara");
using var request = new HttpRequestMessage(HttpMethod.Post, $"{TaraBaseUrl}/v1/payment/verify");
request.Headers.Add("X-Api-Key", TaraApiKey);
request.Content = JsonContent.Create(new
{
token,
orderId = payment.Id.ToString(),
});
var resp = await http.SendAsync(request);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var root = json!.RootElement;
var verified = root.TryGetProperty("status", out var statusEl)
&& (statusEl.GetString()?.ToUpperInvariant() is "OK" or "SUCCESS" or "VERIFIED");
if (verified)
{
var trackId = root.TryGetProperty("trackId", out var tid)
? tid.GetString() ?? token : token;
payment.Status = PaymentStatus.Succeeded;
payment.GatewayTrackId = trackId;
payment.ConfirmedAt = DateTime.UtcNow;
payment.UpdatedAt = DateTime.UtcNow;
if (payment.PlanId.HasValue)
await ActivatePlanAsync(payment);
await db.SaveChangesAsync();
return $"/payment/result?status=success&ref={trackId}";
}
payment.Status = PaymentStatus.Failed;
payment.FailedAt = DateTime.UtcNow;
payment.FailureReason = root.TryGetProperty("message", out var msgEl)
? msgEl.GetString() : "Tara verification failed";
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return "/payment/result?status=failed&gateway=tara";
}
// ── Stripe webhook ────────────────────────────────────────────────────────────
public async Task HandleStripeWebhookAsync(string payload, string signature)
{
if (!VerifyStripeSignature(payload, signature, StripeWebhookSecret))
throw new UnauthorizedAccessException("Invalid Stripe webhook signature");
using var json = JsonDocument.Parse(payload);
var eventType = json.RootElement.GetProperty("type").GetString();
if (eventType != "checkout.session.completed") return;
var sessionObj = json.RootElement.GetProperty("data").GetProperty("object");
var metadata = sessionObj.GetProperty("metadata");
if (!metadata.TryGetProperty("payment_id", out var pidEl)) return;
if (!Guid.TryParse(pidEl.GetString(), out var paymentId)) return;
var payment = await db.Payments.Include(p => p.User).FirstOrDefaultAsync(p => p.Id == paymentId);
if (payment is null || payment.Status != PaymentStatus.Pending) return;
var piId = sessionObj.TryGetProperty("payment_intent", out var pi) ? pi.GetString() : null;
payment.Status = PaymentStatus.Succeeded;
payment.GatewayOrderId = piId;
payment.ConfirmedAt = DateTime.UtcNow;
payment.UpdatedAt = DateTime.UtcNow;
if (payment.PlanId.HasValue)
await ActivatePlanAsync(payment);
await db.SaveChangesAsync();
}
// ── Refunds ───────────────────────────────────────────────────────────────────
public async Task<RefundResponse> IssueRefundAsync(
Guid paymentId, long? amountMinor, string reason, string refundTo)
{
var payment = await db.Payments.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == paymentId)
?? throw new KeyNotFoundException("Payment not found");
if (payment.Status != PaymentStatus.Succeeded)
throw new InvalidOperationException("Only succeeded payments can be refunded");
var refundAmount = amountMinor ?? payment.AmountMinor;
payment.Status = PaymentStatus.Refunded;
payment.RefundedAt = DateTime.UtcNow;
payment.RefundAmountMinor = refundAmount;
payment.RefundReason = reason;
payment.UpdatedAt = DateTime.UtcNow;
// Credit back to user balance (default) or affiliate balance
if (string.IsNullOrEmpty(refundTo) || refundTo == "Balance")
payment.User.BalanceMinor += refundAmount;
else if (refundTo == "Affiliate")
payment.User.AffiliateBalanceMinor += refundAmount;
await db.SaveChangesAsync();
return new RefundResponse(paymentId, "Refunded");
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private async Task ActivatePlanAsync(Payment payment)
{
var plan = await db.Plans.FindAsync(payment.PlanId!.Value);
if (plan is null) return;
var durationMonths = plan.MonthsDuration ?? plan.BillingPeriod switch
{
BillingPeriod.Monthly => 1,
BillingPeriod.Quarterly => 3,
BillingPeriod.SemiAnnual => 6,
BillingPeriod.Annual => 12,
BillingPeriod.Lifetime => 1200,
BillingPeriod.OneTime => 1,
_ => 1,
};
var now = DateTime.UtcNow;
db.UserPlans.Add(new UserPlan
{
UserId = payment.UserId,
TenantId = payment.TenantId,
PlanId = plan.Id,
PlanCode = plan.Code,
PlanName = plan.Name,
PriceMinorPaid = payment.AmountMinor,
Currency = payment.Currency,
InitialSecondsCharge = plan.SecondsCharge,
RemainChargeSec = plan.SecondsCharge,
StartsAt = now,
ExpiresAt = now.AddMonths(durationMonths),
AutoRenew = false,
PaymentId = payment.Id,
});
}
private static bool VerifyStripeSignature(string payload, string header, string secret)
{
// Stripe header: t=<unix_ts>,v1=<hex_sig>[,...]
if (string.IsNullOrEmpty(secret) || string.IsNullOrEmpty(header)) return false;
var ts = "";
var sig = "";
foreach (var part in header.Split(','))
{
if (part.StartsWith("t=")) ts = part[2..];
if (part.StartsWith("v1=")) sig = part[3..];
}
if (string.IsNullOrEmpty(ts) || string.IsNullOrEmpty(sig)) return false;
var message = $"{ts}.{payload}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expectedBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(message));
var expected = Convert.ToHexString(expectedBytes).ToLowerInvariant();
return expected == sig;
}
private static PaymentResponse MapPaymentResponse(Payment p) => new(
p.Id, p.Gateway.ToString(), p.Status.ToString(), p.Action.ToString(),
p.AmountMinor, p.Currency, p.Title, p.Description,
p.CardLast4, p.ConfirmedAt, p.FailedAt, p.FailureReason, p.CreatedAt
);
}
@@ -0,0 +1,170 @@
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Application.Services;
public class PlanService(IdentityDbContext db) : IPlanService
{
public async Task<List<PlanResponse>> ListAsync(Guid tenantId, string? scope)
{
var query = db.Plans.Where(p =>
p.IsActive && p.DeletedAt == null &&
(p.TenantId == null || p.TenantId == tenantId) &&
(p.AvailableFrom == null || p.AvailableFrom <= DateTime.UtcNow) &&
(p.AvailableUntil == null || p.AvailableUntil >= DateTime.UtcNow)
);
if (!string.IsNullOrEmpty(scope) && Enum.TryParse<PlanScope>(scope, true, out var s))
query = query.Where(p => p.Scope == s);
var plans = await query.OrderBy(p => p.Sort).ToListAsync();
return plans.Select(MapPlanResponse).ToList();
}
public async Task<PlanResponse> GetByIdAsync(Guid planId)
{
var plan = await db.Plans.FindAsync(planId)
?? throw new KeyNotFoundException("Plan not found");
return MapPlanResponse(plan);
}
public async Task<UserPlanResponse?> GetCurrentPlanAsync(Guid userId)
{
var plan = await db.UserPlans
.Where(up => up.UserId == userId && up.CancelledAt == null && up.ExpiresAt > DateTime.UtcNow)
.OrderByDescending(up => up.StartsAt)
.FirstOrDefaultAsync();
if (plan == null) return null;
return new UserPlanResponse(
plan.Id, plan.PlanId, plan.PlanCode, plan.PlanName,
plan.InitialSecondsCharge, plan.RemainChargeSec, plan.MonthlyRendersUsed,
plan.StartsAt, plan.ExpiresAt, plan.CancelledAt, plan.AutoRenew
);
}
public async Task<PurchasePlanResponse> PurchasePlanAsync(Guid userId, Guid tenantId, PurchasePlanRequest request)
{
var plan = await db.Plans.FindAsync(request.PlanId)
?? throw new KeyNotFoundException("Plan not found");
if (!plan.IsActive || plan.DeletedAt != null)
throw new InvalidOperationException("Plan is not available");
var gateway = string.IsNullOrEmpty(request.Gateway)
? PaymentGateway.ZarinPal
: Enum.Parse<PaymentGateway>(request.Gateway, true);
long discountAmount = 0;
if (!string.IsNullOrEmpty(request.DiscountCode))
{
var discount = await db.Discounts.FirstOrDefaultAsync(d =>
d.TenantId == tenantId && d.Code == request.DiscountCode &&
d.IsActive && (d.ExpiresAt == null || d.ExpiresAt > DateTime.UtcNow));
if (discount != null)
{
discountAmount = discount.Kind == DiscountKind.Percentage
? (long)(plan.PriceMinor * (double)discount.Value / 100)
: (long)discount.Value;
}
}
var amountDue = Math.Max(0, plan.PriceMinor - discountAmount);
// ── Balance: deduct immediately and activate ───────────────────────────
if (gateway == PaymentGateway.Balance)
{
var user = await db.Users.FindAsync(userId)
?? throw new KeyNotFoundException("User not found");
if (user.BalanceMinor < amountDue)
throw new InvalidOperationException("موجودی کافی نیست");
user.BalanceMinor -= amountDue;
var balancePayment = new Payment
{
TenantId = tenantId,
UserId = userId,
Gateway = PaymentGateway.Balance,
Action = PaymentAction.PlanPurchase,
Status = PaymentStatus.Succeeded,
AmountMinor = amountDue,
Currency = plan.Currency,
DiscountValueMinor = discountAmount,
PlanId = plan.Id,
Title = $"خرید {plan.Name}",
Description = $"Purchase plan: {plan.Code}",
ConfirmedAt = DateTime.UtcNow,
};
db.Payments.Add(balancePayment);
await ActivatePlanForPaymentAsync(balancePayment, plan);
await db.SaveChangesAsync();
return new PurchasePlanResponse(balancePayment.Id, "/dashboard?plan_activated=true");
}
// ── External gateway: create pending payment, return redirect ─────────
var payment = new Payment
{
TenantId = tenantId,
UserId = userId,
Gateway = gateway,
Action = PaymentAction.PlanPurchase,
AmountMinor = amountDue,
Currency = plan.Currency,
DiscountValueMinor = discountAmount,
PlanId = plan.Id,
Title = $"خرید {plan.Name}",
Description = $"Purchase plan: {plan.Code}",
};
db.Payments.Add(payment);
await db.SaveChangesAsync();
var redirectUrl = $"/v1/payments/gateway/{gateway.ToString().ToLower()}?payment_id={payment.Id}";
return new PurchasePlanResponse(payment.Id, redirectUrl);
}
private async Task ActivatePlanForPaymentAsync(Payment payment, Plan plan)
{
var durationMonths = plan.MonthsDuration ?? plan.BillingPeriod switch
{
BillingPeriod.Monthly => 1,
BillingPeriod.Quarterly => 3,
BillingPeriod.SemiAnnual => 6,
BillingPeriod.Annual => 12,
BillingPeriod.Lifetime => 1200,
BillingPeriod.OneTime => 1,
_ => 1,
};
var now = DateTime.UtcNow;
db.UserPlans.Add(new UserPlan
{
UserId = payment.UserId,
TenantId = payment.TenantId,
PlanId = plan.Id,
PlanCode = plan.Code,
PlanName = plan.Name,
PriceMinorPaid = payment.AmountMinor,
Currency = payment.Currency,
InitialSecondsCharge = plan.SecondsCharge,
RemainChargeSec = plan.SecondsCharge,
StartsAt = now,
ExpiresAt = now.AddMonths(durationMonths),
AutoRenew = false,
PaymentId = payment.Id,
});
await Task.CompletedTask; // placeholder for future async work
}
private static PlanResponse MapPlanResponse(Plan p) => new(
p.Id, p.Code, p.Name, p.Description,
p.PriceMinor, p.BeforePriceMinor, p.Currency, p.BillingPeriod.ToString(),
p.SecondsCharge, p.MonthlyRendersQuota, p.StorageGb, p.ParallelRenders,
p.MaxResolution, p.RenderSpeedFactor, p.Icon, p.IsFeatured, p.Features
);
}
@@ -0,0 +1,294 @@
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Application.Services;
public class TenantService(IdentityDbContext db, ITokenService tokenService) : ITenantService
{
public async Task<PagedResponse<TenantResponse>> ListAsync(int page, int pageSize)
{
var total = await db.Tenants.LongCountAsync(t => t.DeletedAt == null);
var tenants = await db.Tenants
.Where(t => t.DeletedAt == null)
.OrderBy(t => t.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResponse<TenantResponse>(
tenants.Select(AuthService.MapTenantResponse).ToList(),
new PaginationMeta(page, pageSize, total, total > (long)page * pageSize)
);
}
public async Task<TenantResponse> CreateAsync(CreateTenantRequest request)
{
var existing = await db.Tenants.AnyAsync(t => t.Slug == request.Slug && t.DeletedAt == null);
if (existing) throw new InvalidOperationException("Slug already taken");
var kind = Enum.TryParse<TenantKind>(request.Kind, true, out var k) ? k : TenantKind.Reseller;
var tenant = new Tenant
{
Slug = request.Slug.ToLower(),
Name = request.Name,
Kind = kind,
ContactName = request.ContactName,
ContactEmail = request.ContactEmail,
ContactPhone = request.ContactPhone,
};
db.Tenants.Add(tenant);
await db.SaveChangesAsync();
return AuthService.MapTenantResponse(tenant);
}
public async Task<TenantResponse> GetByIdAsync(Guid tenantId)
{
var tenant = await db.Tenants.FindAsync(tenantId)
?? throw new KeyNotFoundException("Tenant not found");
return AuthService.MapTenantResponse(tenant);
}
public async Task<TenantResponse> GetBySlugAsync(string slug)
{
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == slug && t.DeletedAt == null)
?? throw new KeyNotFoundException("Tenant not found");
return AuthService.MapTenantResponse(tenant);
}
public async Task<TenantResponse> UpdateAsync(Guid tenantId, UpdateTenantRequest request)
{
var tenant = await db.Tenants.FindAsync(tenantId)
?? throw new KeyNotFoundException("Tenant not found");
if (request.Name != null) tenant.Name = request.Name;
if (request.ContactName != null) tenant.ContactName = request.ContactName;
if (request.ContactEmail != null) tenant.ContactEmail = request.ContactEmail;
if (request.ContactPhone != null) tenant.ContactPhone = request.ContactPhone;
if (request.BillingEmail != null) tenant.BillingEmail = request.BillingEmail;
if (request.AllowedOrigins != null) tenant.AllowedOrigins = request.AllowedOrigins;
tenant.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return AuthService.MapTenantResponse(tenant);
}
public async Task<TenantBrandingResponse> GetBrandingAsync(Guid tenantId)
{
var branding = await db.TenantBrandings.FirstOrDefaultAsync(b => b.TenantId == tenantId)
?? new TenantBranding { TenantId = tenantId };
return MapBrandingResponse(branding);
}
public async Task<TenantBrandingResponse> UpsertBrandingAsync(Guid tenantId, TenantBrandingRequest request)
{
var branding = await db.TenantBrandings.FirstOrDefaultAsync(b => b.TenantId == tenantId);
if (branding == null)
{
branding = new TenantBranding { TenantId = tenantId };
db.TenantBrandings.Add(branding);
}
if (request.DisplayName != null) branding.DisplayName = request.DisplayName;
if (request.LogoUrl != null) branding.LogoUrl = request.LogoUrl;
if (request.LogoDarkUrl != null) branding.LogoDarkUrl = request.LogoDarkUrl;
if (request.FaviconUrl != null) branding.FaviconUrl = request.FaviconUrl;
if (request.OgImageUrl != null) branding.OgImageUrl = request.OgImageUrl;
if (request.PrimaryColor != null) branding.PrimaryColor = request.PrimaryColor;
if (request.SecondaryColor != null) branding.SecondaryColor = request.SecondaryColor;
if (request.AccentColor != null) branding.AccentColor = request.AccentColor;
if (request.BackgroundColor != null) branding.BackgroundColor = request.BackgroundColor;
if (request.FontFamily != null) branding.FontFamily = request.FontFamily;
if (request.EmailFromName != null) branding.EmailFromName = request.EmailFromName;
if (request.EmailFromAddress != null) branding.EmailFromAddress = request.EmailFromAddress;
if (request.EmailReplyTo != null) branding.EmailReplyTo = request.EmailReplyTo;
if (request.EmailFooterHtml != null) branding.EmailFooterHtml = request.EmailFooterHtml;
if (request.SupportUrl != null) branding.SupportUrl = request.SupportUrl;
if (request.TermsUrl != null) branding.TermsUrl = request.TermsUrl;
if (request.PrivacyUrl != null) branding.PrivacyUrl = request.PrivacyUrl;
if (request.EmbedEnabled.HasValue) branding.EmbedEnabled = request.EmbedEnabled.Value;
if (request.EmbedAllowedHosts != null) branding.EmbedAllowedHosts = request.EmbedAllowedHosts;
if (request.WatermarkText != null) branding.WatermarkText = request.WatermarkText;
if (request.WatermarkImageUrl != null) branding.WatermarkImageUrl = request.WatermarkImageUrl;
if (request.WatermarkEnabled.HasValue) branding.WatermarkEnabled = request.WatermarkEnabled.Value;
branding.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return MapBrandingResponse(branding);
}
public async Task<DomainVerificationResponse> StartDomainVerificationAsync(Guid tenantId, string domain, string method)
{
var tenant = await db.Tenants.FindAsync(tenantId)
?? throw new KeyNotFoundException("Tenant not found");
// Generate a unique verification challenge
var challenge = $"flatrender-verify={tokenService.HashToken(Guid.NewGuid().ToString())[..32]}";
// For now just return the challenge — actual DNS checking would be via a background job
return new DomainVerificationResponse(
Guid.NewGuid(),
challenge,
DateTime.UtcNow.AddDays(7)
);
}
public async Task<List<TenantUsageDayResponse>> GetUsageAsync(Guid tenantId, DateOnly from, DateOnly to)
{
var rows = await db.TenantUsageDailies
.Where(u => u.TenantId == tenantId && u.UsageDate >= from && u.UsageDate <= to)
.OrderBy(u => u.UsageDate)
.ToListAsync();
return rows.Select(r => new TenantUsageDayResponse(
r.UsageDate, r.RendersCompleted, r.RenderSeconds,
r.StorageBytes, r.ApiCalls, r.ActiveUsers,
r.AmountBilledMinor, r.BillingCurrency, r.BillingStatus
)).ToList();
}
// ── API Keys ─────────────────────────────────────────────────────────
public async Task<List<ApiKeyResponse>> GetApiKeysAsync(Guid tenantId)
{
var keys = await db.TenantApiKeys
.Where(k => k.TenantId == tenantId && k.RevokedAt == null)
.OrderByDescending(k => k.CreatedAt)
.ToListAsync();
return keys.Select(MapApiKeyResponse).ToList();
}
public async Task<ApiKeyCreatedResponse> CreateApiKeyAsync(Guid tenantId, Guid createdByUserId, CreateApiKeyRequest request)
{
var rawSecret = $"fr_{request.Environment.ToLower()[..4]}_{Guid.NewGuid():N}{Guid.NewGuid():N}";
var prefix = rawSecret[..16];
var last4 = rawSecret[^4..];
var hash = tokenService.HashToken(rawSecret);
var key = new TenantApiKey
{
TenantId = tenantId,
CreatedByUserId = createdByUserId,
Name = request.Name,
Environment = request.Environment,
KeyPrefix = prefix,
KeyHash = hash,
Last4 = last4,
Scopes = request.Scopes,
AllowedIps = request.AllowedIps ?? [],
RateLimitRpm = request.RateLimitRpm,
ExpiresAt = request.ExpiresAt,
};
db.TenantApiKeys.Add(key);
await db.SaveChangesAsync();
return new ApiKeyCreatedResponse(key.Id, tenantId, key.Name, key.Environment, prefix, last4, key.Scopes, rawSecret, key.CreatedAt);
}
public async Task RevokeApiKeyAsync(Guid tenantId, Guid apiKeyId, string? reason)
{
var key = await db.TenantApiKeys.FirstOrDefaultAsync(k => k.Id == apiKeyId && k.TenantId == tenantId)
?? throw new KeyNotFoundException("API key not found");
key.RevokedAt = DateTime.UtcNow;
key.RevokeReason = reason;
key.IsActive = false;
await db.SaveChangesAsync();
}
public async Task<ApiKeyValidateResponse> ValidateApiKeyAsync(string keyPrefix, string keyHash, string? ipAddress)
{
var key = await db.TenantApiKeys
.FirstOrDefaultAsync(k => k.KeyPrefix == keyPrefix && k.IsActive && k.RevokedAt == null &&
(k.ExpiresAt == null || k.ExpiresAt > DateTime.UtcNow));
if (key == null || key.KeyHash != keyHash)
return new ApiKeyValidateResponse(false, null, null, null);
if (key.AllowedIps.Length > 0 && !string.IsNullOrEmpty(ipAddress) && !key.AllowedIps.Contains(ipAddress))
return new ApiKeyValidateResponse(false, null, null, null);
key.UsageCount++;
key.LastUsedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new ApiKeyValidateResponse(true, key.TenantId, key.Scopes, key.RateLimitRpm);
}
// ── Webhooks ──────────────────────────────────────────────────────────
public async Task<List<WebhookResponse>> GetWebhooksAsync(Guid tenantId)
{
var hooks = await db.TenantWebhooks
.Where(w => w.TenantId == tenantId)
.OrderByDescending(w => w.CreatedAt)
.ToListAsync();
return hooks.Select(w => new WebhookResponse(
w.Id, w.Name, w.Url, w.Events, w.IsActive,
w.LastTriggeredAt, w.LastStatusCode, w.ConsecutiveFailures, w.CreatedAt
)).ToList();
}
public async Task<WebhookResponse> CreateWebhookAsync(Guid tenantId, CreateWebhookRequest request)
{
_ = await db.Tenants.FindAsync(tenantId) ?? throw new KeyNotFoundException("Tenant not found");
var hook = new TenantWebhook
{
TenantId = tenantId,
Name = request.Name,
Url = request.Url,
Events = request.Events,
SecretHash = tokenService.HashToken(Guid.NewGuid().ToString()),
};
db.TenantWebhooks.Add(hook);
await db.SaveChangesAsync();
return new WebhookResponse(hook.Id, hook.Name, hook.Url, hook.Events, hook.IsActive,
hook.LastTriggeredAt, hook.LastStatusCode, hook.ConsecutiveFailures, hook.CreatedAt);
}
public async Task DeleteWebhookAsync(Guid tenantId, Guid webhookId)
{
var hook = await db.TenantWebhooks.FirstOrDefaultAsync(w => w.Id == webhookId && w.TenantId == tenantId)
?? throw new KeyNotFoundException("Webhook not found");
db.TenantWebhooks.Remove(hook);
await db.SaveChangesAsync();
}
public async Task<List<WebhookDeliveryResponse>> GetWebhookDeliveriesAsync(Guid tenantId, Guid webhookId)
{
_ = await db.TenantWebhooks.FirstOrDefaultAsync(w => w.Id == webhookId && w.TenantId == tenantId)
?? throw new KeyNotFoundException("Webhook not found");
var deliveries = await db.TenantWebhookDeliveries
.Where(d => d.WebhookId == webhookId)
.OrderByDescending(d => d.CreatedAt)
.Take(50)
.ToListAsync();
return deliveries.Select(d => new WebhookDeliveryResponse(
d.Id, d.EventType, d.RequestUrl, d.ResponseStatus, d.ResponseBody,
d.DurationMs, d.Attempt, d.Succeeded, d.ErrorMessage, d.DeliveredAt, d.CreatedAt
)).ToList();
}
// ── Helpers ───────────────────────────────────────────────────────────
private static ApiKeyResponse MapApiKeyResponse(TenantApiKey k) => new(
k.Id, k.TenantId, k.Name, k.Environment, k.KeyPrefix, k.Last4,
k.Scopes, k.AllowedIps, k.RateLimitRpm, k.IsActive,
k.ExpiresAt, k.LastUsedAt, k.UsageCount, k.CreatedAt
);
private static TenantBrandingResponse MapBrandingResponse(TenantBranding b) => new(
b.TenantId, b.DisplayName, b.LogoUrl, b.LogoDarkUrl,
b.PrimaryColor, b.SecondaryColor, b.AccentColor,
b.EmbedEnabled, b.WatermarkEnabled
);
}
@@ -0,0 +1,95 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Entities;
using Microsoft.IdentityModel.Tokens;
namespace FlatRender.IdentitySvc.Application.Services;
public class TokenService(IConfiguration config) : ITokenService
{
private readonly string _secret = config["Jwt:Secret"]
?? throw new InvalidOperationException("Jwt:Secret not configured");
private readonly string _issuer = config["Jwt:Issuer"] ?? "flatrender-identity";
private readonly string _audience = config["Jwt:Audience"] ?? "flatrender";
private readonly int _accessTokenMinutes = int.Parse(config["Jwt:AccessTokenMinutes"] ?? "15");
public string GenerateAccessToken(User user, Tenant tenant)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("tenant_id", tenant.Id.ToString()),
new("tenant_slug", tenant.Slug),
new("is_admin", user.IsAdmin.ToString().ToLower()),
new("is_tenant_admin", user.IsTenantAdmin.ToString().ToLower()),
};
if (!string.IsNullOrEmpty(user.Email))
claims.Add(new(JwtRegisteredClaimNames.Email, user.Email));
var token = new JwtSecurityToken(
issuer: _issuer,
audience: _audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_accessTokenMinutes),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public string GenerateRefreshToken()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
public string HashToken(string token)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return Convert.ToHexString(bytes).ToLower();
}
public (Guid userId, Guid tenantId, bool isAdmin) ValidateAccessToken(string token)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
var handler = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ValidateIssuer = true,
ValidIssuer = _issuer,
ValidateAudience = true,
ValidAudience = _audience,
ValidateLifetime = true,
};
var principal = handler.ValidateToken(token, parameters, out _);
var userId = Guid.Parse(principal.FindFirstValue(JwtRegisteredClaimNames.Sub)!);
var tenantId = Guid.Parse(principal.FindFirstValue("tenant_id")!);
var isAdmin = bool.Parse(principal.FindFirstValue("is_admin") ?? "false");
return (userId, tenantId, isAdmin);
}
public string GenerateServiceToken()
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _issuer,
audience: _audience,
claims: [new("type", "service"), new("service", "identity")],
expires: DateTime.UtcNow.AddHours(24),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,128 @@
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Application.Services;
public class UserService(IdentityDbContext db) : IUserService
{
public async Task<UserResponse> GetMeAsync(Guid userId)
{
var user = await db.Users.FindAsync(userId)
?? throw new KeyNotFoundException("User not found");
return AuthService.MapUserResponse(user);
}
public async Task<UserResponse> UpdateMeAsync(Guid userId, UpdateUserRequest request)
{
var user = await db.Users.FindAsync(userId)
?? throw new KeyNotFoundException("User not found");
if (request.FullName != null) user.FullName = request.FullName;
if (request.Slogan != null) user.Slogan = request.Slogan;
if (request.AboutMe != null) user.AboutMe = request.AboutMe;
if (request.CompanyName != null) user.CompanyName = request.CompanyName;
if (request.WebsiteName != null) user.WebsiteName = request.WebsiteName;
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate;
if (request.Gender != null && Enum.TryParse<GenderKind>(request.Gender, true, out var gender))
user.Gender = gender;
if (request.EmailTellMe.HasValue) user.EmailTellMe = request.EmailTellMe.Value;
if (request.SmsTellMe.HasValue) user.SmsTellMe = request.SmsTellMe.Value;
if (request.PushTellMe.HasValue) user.PushTellMe = request.PushTellMe.Value;
if (request.TelegramTellMe.HasValue) user.TelegramTellMe = request.TelegramTellMe.Value;
user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return AuthService.MapUserResponse(user);
}
public async Task<BalanceResponse> GetBalanceAsync(Guid userId)
{
var user = await db.Users.FindAsync(userId)
?? throw new KeyNotFoundException("User not found");
return new BalanceResponse(
user.BalanceMinor,
user.AffiliateBalanceMinor,
"IRR",
user.DailyRemainRenderCount,
user.ParallelRenderingCeiling
);
}
public async Task UpdateAvatarAsync(Guid userId, Guid? avatarId, string? avatarUrl)
{
var user = await db.Users.FindAsync(userId)
?? throw new KeyNotFoundException("User not found");
if (avatarId.HasValue)
{
var avatar = await db.Avatars.FindAsync(avatarId.Value);
if (avatar != null) user.AvatarUrl = avatar.Url;
}
else if (!string.IsNullOrEmpty(avatarUrl))
{
user.AvatarUrl = avatarUrl;
}
user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<UserResponse> GetByIdAsync(Guid userId)
{
var user = await db.Users.FindAsync(userId)
?? throw new KeyNotFoundException("User not found");
return AuthService.MapUserResponse(user);
}
public async Task<PagedResponse<UserResponse>> SearchAsync(string? q, Guid? tenantId, int page, int pageSize)
{
var query = db.Users.Where(u => u.DeletedAt == null);
if (tenantId.HasValue)
query = query.Where(u => u.TenantId == tenantId.Value);
if (!string.IsNullOrWhiteSpace(q))
query = query.Where(u =>
(u.Email != null && u.Email.Contains(q)) ||
(u.FullName != null && EF.Functions.ILike(u.FullName, $"%{q}%")) ||
(u.PhoneNumber != null && u.PhoneNumber.Contains(q)));
var total = await query.LongCountAsync();
var users = await query
.OrderByDescending(u => u.RegisterDate)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResponse<UserResponse>(
users.Select(AuthService.MapUserResponse).ToList(),
new PaginationMeta(page, pageSize, total, total > (long)page * pageSize)
);
}
public async Task BanAsync(Guid userId, string reason, DateTime? unblockDate)
{
var user = await db.Users.FindAsync(userId)
?? throw new KeyNotFoundException("User not found");
user.BanAccount = true;
user.BanReason = reason;
user.UnblockDate = unblockDate;
user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task UnbanAsync(Guid userId)
{
var user = await db.Users.FindAsync(userId)
?? throw new KeyNotFoundException("User not found");
user.BanAccount = false;
user.BanReason = null;
user.UnblockDate = null;
user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}