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
+19
View File
@@ -0,0 +1,19 @@
# Build output — rebuilt inside container
**/bin/
**/obj/
# Local dev secrets
**/appsettings.Development.json
**/appsettings.*.Local.json
**/*.user
# IDE / OS
.vs/
.idea/
.DS_Store
Thumbs.db
# Docker files
Dockerfile
.dockerignore
docker-compose*.yml
+24
View File
@@ -0,0 +1,24 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
# The .NET base image ships neither wget nor curl, which the container healthcheck needs.
# Copy a single static busybox binary named `wget` (busybox dispatches on argv[0]).
# This stays fully offline — no apt/network — matching the vendored Go builds.
COPY --from=busybox:1.36 /bin/busybox /usr/bin/wget
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore is its own cached layer: it only re-runs when the .csproj (deps) changes,
# not on every source edit. Critical here — NuGet restore is the slow step.
COPY NuGet.Config .
COPY ["FlatRender.IdentitySvc/FlatRender.IdentitySvc.csproj", "FlatRender.IdentitySvc/"]
RUN dotnet restore "FlatRender.IdentitySvc/FlatRender.IdentitySvc.csproj"
COPY . .
# Single publish compiles + packages; --no-restore reuses the cached restore above.
RUN dotnet publish "FlatRender.IdentitySvc/FlatRender.IdentitySvc.csproj" \
-c Release -o /app/publish --no-restore /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "FlatRender.IdentitySvc.dll"]
@@ -0,0 +1,3 @@
<Solution>
<Project Path="FlatRender.IdentitySvc/FlatRender.IdentitySvc.csproj" />
</Solution>
@@ -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();
}
}
@@ -0,0 +1,176 @@
using FlatRender.IdentitySvc.Application.Services;
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.IdentitySvc.Controllers;
[ApiController]
[Route("v1/auth")]
public class AuthController(IAuthService authService) : ControllerBase
{
[HttpPost("register")]
[AllowAnonymous]
[ProducesResponseType(typeof(RegisterResponse), 201)]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var result = await authService.RegisterAsync(request, GetClientIp());
return StatusCode(201, result);
}
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthTokensResponse), 200)]
[ProducesResponseType(401)]
[ProducesResponseType(403)]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
try
{
var result = await authService.LoginAsync(request, GetClientIp());
return Ok(result);
}
catch (MfaRequiredException mfaEx)
{
return StatusCode(403, new { mfa_required = true, mfa_token = mfaEx.MfaToken });
}
}
[HttpPost("refresh")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthTokensResponse), 200)]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request)
{
var result = await authService.RefreshAsync(request.RefreshToken);
return Ok(result);
}
[HttpPost("logout")]
[Authorize]
[ProducesResponseType(204)]
public async Task<IActionResult> Logout()
{
await authService.LogoutAsync(GetSessionId(), GetUserId());
return NoContent();
}
[HttpGet("sessions")]
[Authorize]
[ProducesResponseType(typeof(object), 200)]
public async Task<IActionResult> GetSessions()
{
var sessions = await authService.GetSessionsAsync(GetUserId());
return Ok(new { sessions });
}
[HttpDelete("sessions/{sessionId:guid}")]
[Authorize]
[ProducesResponseType(204)]
public async Task<IActionResult> RevokeSession(Guid sessionId)
{
await authService.RevokeSessionAsync(sessionId, GetUserId());
return NoContent();
}
[HttpPost("verify/email")]
[AllowAnonymous]
public async Task<IActionResult> VerifyEmail([FromBody] VerifyOtpRequest request)
{
var ok = await authService.VerifyEmailAsync(request.Token, request.Code);
return ok ? Ok() : BadRequest(new { error = "Invalid code" });
}
[HttpPost("verify/phone")]
[AllowAnonymous]
public async Task<IActionResult> VerifyPhone([FromBody] VerifyOtpRequest request)
{
var ok = await authService.VerifyPhoneAsync(request.Token, request.Code);
return ok ? Ok() : BadRequest(new { error = "Invalid code" });
}
[HttpPost("password/reset/request")]
[AllowAnonymous]
[ProducesResponseType(202)]
public async Task<IActionResult> PasswordResetRequest([FromBody] PasswordResetRequestDto request)
{
await authService.RequestPasswordResetAsync(request.TenantSlug, request.Email, request.PhoneNumber);
return Accepted();
}
[HttpPost("password/reset/confirm")]
[AllowAnonymous]
public async Task<IActionResult> PasswordResetConfirm([FromBody] PasswordResetConfirmRequest request)
{
await authService.ConfirmPasswordResetAsync(request.Token, request.NewPassword);
return Ok();
}
[HttpPost("password/change")]
[Authorize]
[ProducesResponseType(204)]
public async Task<IActionResult> PasswordChange([FromBody] PasswordChangeRequest request)
{
await authService.ChangePasswordAsync(GetUserId(), request.CurrentPassword, request.NewPassword);
return NoContent();
}
[HttpPost("mfa/setup")]
[Authorize]
[ProducesResponseType(typeof(MfaSetupResponse), 200)]
public async Task<IActionResult> MfaSetup([FromBody] MfaSetupRequest request)
{
var result = await authService.SetupMfaAsync(GetUserId(), request.FactorType, request.Label);
return Ok(result);
}
[HttpPost("mfa/verify")]
[Authorize]
public async Task<IActionResult> MfaVerify([FromBody] MfaVerifyRequest request)
{
var ok = await authService.VerifyMfaAsync(GetUserId(), request.FactorId, request.Code);
return ok ? Ok() : BadRequest(new { error = "Invalid code" });
}
[HttpPost("mfa/challenge")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthTokensResponse), 200)]
public async Task<IActionResult> MfaChallenge([FromBody] MfaChallengeRequest request)
{
var result = await authService.ChallengeMfaAsync(request.MfaToken, request.Code);
return Ok(result);
}
[HttpPost("push/subscribe")]
[Authorize]
[ProducesResponseType(201)]
public async Task<IActionResult> PushSubscribe([FromBody] PushSubscribeRequest request)
{
await authService.SubscribePushAsync(
GetUserId(), GetTenantId(),
request.Endpoint, request.Keys.P256dh, request.Keys.Auth, request.UserAgent);
return StatusCode(201);
}
[HttpPost("push/unsubscribe")]
[Authorize]
[ProducesResponseType(204)]
public async Task<IActionResult> PushUnsubscribe([FromBody] PushUnsubscribeRequest request)
{
await authService.UnsubscribePushAsync(GetUserId(), request.Endpoint);
return NoContent();
}
// ── Helpers ───────────────────────────────────────────────────────────
private Guid GetUserId() => Guid.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException());
private Guid GetTenantId() => Guid.Parse(User.FindFirst("tenant_id")?.Value
?? throw new UnauthorizedAccessException());
private Guid GetSessionId() => Guid.Parse(User.FindFirst("jti")?.Value ?? Guid.Empty.ToString());
private string? GetClientIp() => HttpContext.Connection.RemoteIpAddress?.ToString();
}
@@ -0,0 +1,48 @@
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.IdentitySvc.Controllers;
[ApiController]
[Route("v1/discounts")]
[Authorize]
public class DiscountsController(IDiscountService discountService) : ControllerBase
{
[HttpPost("validate")]
[AllowAnonymous]
[ProducesResponseType(typeof(DiscountValidateResponse), 200)]
public async Task<IActionResult> Validate([FromBody] ValidateDiscountRequest request)
{
var tenantId = GetTenantIdOrDefault();
var result = await discountService.ValidateAsync(tenantId, request.Code, request.PlanId);
return Ok(result);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<DiscountResponse>), 200)]
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var result = await discountService.ListAsync(GetTenantId(), page, pageSize);
return Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(DiscountResponse), 201)]
public async Task<IActionResult> Create([FromBody] CreateDiscountRequest request)
{
var result = await discountService.CreateAsync(GetTenantId(), request);
return StatusCode(201, result);
}
private Guid GetTenantId() => Guid.Parse(User.FindFirst("tenant_id")?.Value
?? throw new UnauthorizedAccessException());
private Guid GetTenantIdOrDefault()
{
var claim = User.FindFirst("tenant_id")?.Value;
return claim != null ? Guid.Parse(claim) : Guid.Empty;
}
}
@@ -0,0 +1,49 @@
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.IdentitySvc.Controllers;
[ApiController]
[Authorize]
public class GamificationController(IGamificationService gamificationService) : ControllerBase
{
[HttpGet("v1/quests")]
[ProducesResponseType(typeof(object), 200)]
public async Task<IActionResult> GetQuests()
{
var quests = await gamificationService.GetActiveQuestsAsync(GetUserId(), GetTenantId());
return Ok(new { data = quests });
}
[HttpPost("v1/quests/{questId:guid}/claim")]
[ProducesResponseType(200)]
public async Task<IActionResult> ClaimQuest(Guid questId)
{
await gamificationService.ClaimQuestPrizeAsync(GetUserId(), questId);
return Ok();
}
[HttpGet("v1/gifts/earned")]
[ProducesResponseType(typeof(object), 200)]
public async Task<IActionResult> GetEarnedGifts()
{
var gifts = await gamificationService.GetEarnedGiftsAsync(GetUserId());
return Ok(new { data = gifts });
}
[HttpPost("v1/gifts/earned/{earnedGiftId:guid}/use")]
[ProducesResponseType(200)]
public async Task<IActionResult> UseGift(Guid earnedGiftId)
{
await gamificationService.UseEarnedGiftAsync(GetUserId(), earnedGiftId);
return Ok();
}
private Guid GetUserId() => Guid.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException());
private Guid GetTenantId() => Guid.Parse(User.FindFirst("tenant_id")?.Value
?? throw new UnauthorizedAccessException());
}
@@ -0,0 +1,159 @@
using FlatRender.IdentitySvc.Application.Services;
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Models.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.IdentitySvc.Controllers;
[ApiController]
[Route("v1")]
public class PaymentsController(IPaymentService paymentService) : ControllerBase
{
// ── Helpers ───────────────────────────────────────────────────────────────────
private Guid GetUserId() => Guid.Parse(
User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value
?? throw new UnauthorizedAccessException());
private bool IsAdmin => User.FindFirst("is_admin")?.Value == "true";
// ── Listing ───────────────────────────────────────────────────────────────────
/// <summary>GET /v1/payments — list the caller's payment history</summary>
[Authorize]
[HttpGet("payments")]
public async Task<IActionResult> List(
[FromQuery] int page = 1,
[FromQuery] int page_size = 20)
=> Ok(await paymentService.GetUserPaymentsAsync(GetUserId(), page, page_size));
/// <summary>GET /v1/payments/{id} — get a single payment</summary>
[Authorize]
[HttpGet("payments/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
=> Ok(await paymentService.GetByIdAsync(id, GetUserId()));
// ── ZarinPal flow ─────────────────────────────────────────────────────────────
/// <summary>
/// GET /v1/payments/gateway/zarinpal?payment_id={id}
/// Initiates a ZarinPal payment and redirects the browser to zarinpal.com.
/// Called from the frontend after PurchasePlan returns the redirect URL.
/// </summary>
[Authorize]
[HttpGet("payments/gateway/zarinpal")]
public async Task<IActionResult> InitiateZarinPal([FromQuery] Guid payment_id)
{
var redirectUrl = await paymentService.InitiateZarinPalAsync(payment_id, GetUserId());
return Redirect(redirectUrl);
}
/// <summary>
/// GET /v1/payments/callback/zarinpal?Authority={a}&amp;Status={s}
/// ZarinPal calls this URL after the user completes (or cancels) payment.
/// Verifies with ZarinPal, activates the plan, then redirects to the frontend.
/// </summary>
[AllowAnonymous]
[HttpGet("payments/callback/zarinpal")]
public async Task<IActionResult> ZarinPalCallback(
[FromQuery] string Authority,
[FromQuery] string Status)
{
var frontendUrl = await paymentService.HandleZarinPalCallbackAsync(Authority, Status);
return Redirect(frontendUrl);
}
// ── SnapPay flow ──────────────────────────────────────────────────────────────
/// <summary>
/// GET /v1/payments/gateway/snappay?payment_id={id}
/// Initiates a SnapPay payment and redirects the browser to snappay.ir.
/// </summary>
[Authorize]
[HttpGet("payments/gateway/snappay")]
public async Task<IActionResult> InitiateSnapPay([FromQuery] Guid payment_id)
{
var redirectUrl = await paymentService.InitiateSnapPayAsync(payment_id, GetUserId());
return Redirect(redirectUrl);
}
/// <summary>
/// GET /v1/payments/callback/snappay?paymentToken={t}&amp;shapSnapStatus={s}
/// SnapPay calls this URL after the user completes (or cancels) payment.
/// Verifies with SnapPay, activates the plan, then redirects to the frontend.
/// </summary>
[AllowAnonymous]
[HttpGet("payments/callback/snappay")]
public async Task<IActionResult> SnapPayCallback(
[FromQuery] string paymentToken,
[FromQuery] string shapSnapStatus)
{
var frontendUrl = await paymentService.HandleSnapPayCallbackAsync(paymentToken, shapSnapStatus);
return Redirect(frontendUrl);
}
// ── Tara flow ─────────────────────────────────────────────────────────────────
/// <summary>
/// GET /v1/payments/gateway/tara?payment_id={id}
/// Initiates a Tara payment and redirects the browser to tara.ir.
/// </summary>
[Authorize]
[HttpGet("payments/gateway/tara")]
public async Task<IActionResult> InitiateTara([FromQuery] Guid payment_id)
{
var redirectUrl = await paymentService.InitiateTaraAsync(payment_id, GetUserId());
return Redirect(redirectUrl);
}
/// <summary>
/// GET /v1/payments/callback/tara?token={t}&amp;status={s}
/// Tara calls this URL after the user completes (or cancels) payment.
/// Verifies with Tara, activates the plan, then redirects to the frontend.
/// </summary>
[AllowAnonymous]
[HttpGet("payments/callback/tara")]
public async Task<IActionResult> TaraCallback(
[FromQuery] string token,
[FromQuery] string status)
{
var frontendUrl = await paymentService.HandleTaraCallbackAsync(token, status);
return Redirect(frontendUrl);
}
// ── Stripe webhook ────────────────────────────────────────────────────────────
/// <summary>
/// POST /v1/payments/webhook/stripe
/// Receives Stripe webhook events. Must be reachable from the public internet.
/// Register this URL in your Stripe dashboard under Developers → Webhooks.
/// </summary>
[AllowAnonymous]
[HttpPost("payments/webhook/stripe")]
public async Task<IActionResult> StripeWebhook()
{
using var reader = new StreamReader(Request.Body);
var payload = await reader.ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"].ToString();
await paymentService.HandleStripeWebhookAsync(payload, signature);
return Ok();
}
// ── Admin ─────────────────────────────────────────────────────────────────────
/// <summary>
/// POST /v1/admin/payments/{id}/refund
/// Issues a refund for a payment. Admin-only.
/// </summary>
[Authorize]
[HttpPost("admin/payments/{id:guid}/refund")]
public async Task<IActionResult> Refund(Guid id, [FromBody] IssueRefundRequest request)
{
if (!IsAdmin) return Forbid();
var result = await paymentService.IssueRefundAsync(
id, request.AmountMinor, request.Reason, request.RefundTo);
return Ok(result);
}
}
@@ -0,0 +1,46 @@
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.IdentitySvc.Controllers;
[ApiController]
[Route("v1")]
[Authorize]
public class PlansController(IPlanService planService) : ControllerBase
{
[HttpGet("plans")]
[ProducesResponseType(typeof(object), 200)]
public async Task<IActionResult> List([FromQuery] string? scope)
{
var tenantId = GetTenantId();
var plans = await planService.ListAsync(tenantId, scope);
return Ok(new { data = plans });
}
[HttpGet("plans/{planId:guid}")]
[ProducesResponseType(typeof(PlanResponse), 200)]
public async Task<IActionResult> GetById(Guid planId)
=> Ok(await planService.GetByIdAsync(planId));
[HttpGet("users/me/plan")]
[ProducesResponseType(typeof(UserPlanResponse), 200)]
public async Task<IActionResult> GetCurrentPlan()
=> Ok(await planService.GetCurrentPlanAsync(GetUserId()));
[HttpPost("users/me/plan/purchase")]
[ProducesResponseType(typeof(PurchasePlanResponse), 200)]
public async Task<IActionResult> Purchase([FromBody] PurchasePlanRequest request)
{
var result = await planService.PurchasePlanAsync(GetUserId(), GetTenantId(), request);
return Ok(result);
}
private Guid GetUserId() => Guid.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException());
private Guid GetTenantId() => Guid.Parse(User.FindFirst("tenant_id")?.Value
?? throw new UnauthorizedAccessException());
}
@@ -0,0 +1,125 @@
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.IdentitySvc.Controllers;
[ApiController]
[Route("v1/tenants")]
[Authorize]
public class TenantsController(ITenantService tenantService) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TenantResponse>), 200)]
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
=> Ok(await tenantService.ListAsync(page, pageSize));
[HttpPost]
[ProducesResponseType(typeof(TenantResponse), 201)]
public async Task<IActionResult> Create([FromBody] CreateTenantRequest request)
{
var result = await tenantService.CreateAsync(request);
return StatusCode(201, result);
}
[HttpGet("by-slug/{slug}")]
[AllowAnonymous]
[ProducesResponseType(typeof(TenantResponse), 200)]
public async Task<IActionResult> GetBySlug(string slug)
=> Ok(await tenantService.GetBySlugAsync(slug));
[HttpGet("{tenantId:guid}")]
[ProducesResponseType(typeof(TenantResponse), 200)]
public async Task<IActionResult> GetById(Guid tenantId)
=> Ok(await tenantService.GetByIdAsync(tenantId));
[HttpPatch("{tenantId:guid}")]
[ProducesResponseType(typeof(TenantResponse), 200)]
public async Task<IActionResult> Update(Guid tenantId, [FromBody] UpdateTenantRequest request)
=> Ok(await tenantService.UpdateAsync(tenantId, request));
[HttpGet("{tenantId:guid}/branding")]
[ProducesResponseType(typeof(TenantBrandingResponse), 200)]
public async Task<IActionResult> GetBranding(Guid tenantId)
=> Ok(await tenantService.GetBrandingAsync(tenantId));
[HttpPut("{tenantId:guid}/branding")]
[ProducesResponseType(typeof(TenantBrandingResponse), 200)]
public async Task<IActionResult> UpsertBranding(Guid tenantId, [FromBody] TenantBrandingRequest request)
=> Ok(await tenantService.UpsertBrandingAsync(tenantId, request));
[HttpPost("{tenantId:guid}/domains/verify")]
[ProducesResponseType(typeof(DomainVerificationResponse), 200)]
public async Task<IActionResult> VerifyDomain(Guid tenantId, [FromBody] StartDomainVerificationRequest request)
=> Ok(await tenantService.StartDomainVerificationAsync(tenantId, request.Domain, request.Method));
[HttpGet("{tenantId:guid}/usage")]
[ProducesResponseType(typeof(object), 200)]
public async Task<IActionResult> GetUsage(
Guid tenantId,
[FromQuery] DateOnly from,
[FromQuery] DateOnly to)
{
var data = await tenantService.GetUsageAsync(tenantId, from, to);
return Ok(new { data });
}
// ── API Keys ──────────────────────────────────────────────────────────
[HttpGet("{tenantId:guid}/api-keys")]
public async Task<IActionResult> GetApiKeys(Guid tenantId)
=> Ok(new { data = await tenantService.GetApiKeysAsync(tenantId) });
[HttpPost("{tenantId:guid}/api-keys")]
public async Task<IActionResult> CreateApiKey(Guid tenantId, [FromBody] CreateApiKeyRequest request)
{
var userId = GetUserId();
var result = await tenantService.CreateApiKeyAsync(tenantId, userId, request);
return StatusCode(201, result);
}
[HttpDelete("{tenantId:guid}/api-keys/{apiKeyId:guid}")]
public async Task<IActionResult> RevokeApiKey(Guid tenantId, Guid apiKeyId, [FromBody] RevokeApiKeyRequest? request)
{
await tenantService.RevokeApiKeyAsync(tenantId, apiKeyId, request?.Reason);
return NoContent();
}
// ── Webhooks ──────────────────────────────────────────────────────────
[HttpGet("{tenantId:guid}/webhooks")]
public async Task<IActionResult> GetWebhooks(Guid tenantId)
=> Ok(new { data = await tenantService.GetWebhooksAsync(tenantId) });
[HttpPost("{tenantId:guid}/webhooks")]
public async Task<IActionResult> CreateWebhook(Guid tenantId, [FromBody] CreateWebhookRequest request)
{
var result = await tenantService.CreateWebhookAsync(tenantId, request);
return StatusCode(201, result);
}
[HttpDelete("{tenantId:guid}/webhooks/{webhookId:guid}")]
public async Task<IActionResult> DeleteWebhook(Guid tenantId, Guid webhookId)
{
await tenantService.DeleteWebhookAsync(tenantId, webhookId);
return NoContent();
}
[HttpGet("{tenantId:guid}/webhooks/{webhookId:guid}/deliveries")]
public async Task<IActionResult> GetWebhookDeliveries(Guid tenantId, Guid webhookId)
=> Ok(new { data = await tenantService.GetWebhookDeliveriesAsync(tenantId, webhookId) });
private Guid GetUserId() => Guid.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException());
}
[ApiController]
[Route("v1/api-keys")]
public class ApiKeyValidationController(ITenantService tenantService) : ControllerBase
{
[HttpPost("validate")]
public async Task<IActionResult> Validate([FromBody] ValidateApiKeyRequest request)
=> Ok(await tenantService.ValidateApiKeyAsync(request.KeyPrefix, request.KeyHash, request.IpAddress));
}
@@ -0,0 +1,61 @@
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.IdentitySvc.Controllers;
[ApiController]
[Route("v1/users")]
[Authorize]
public class UsersController(IUserService userService) : ControllerBase
{
[HttpGet("me")]
[ProducesResponseType(typeof(UserResponse), 200)]
public async Task<IActionResult> GetMe()
=> Ok(await userService.GetMeAsync(GetUserId()));
[HttpPatch("me")]
[ProducesResponseType(typeof(UserResponse), 200)]
public async Task<IActionResult> UpdateMe([FromBody] UpdateUserRequest request)
=> Ok(await userService.UpdateMeAsync(GetUserId(), request));
[HttpGet("me/balance")]
[ProducesResponseType(typeof(BalanceResponse), 200)]
public async Task<IActionResult> GetBalance()
=> Ok(await userService.GetBalanceAsync(GetUserId()));
[HttpPost("me/avatar")]
public async Task<IActionResult> SetAvatar([FromBody] SetAvatarRequest request)
{
await userService.UpdateAvatarAsync(GetUserId(), request.AvatarId, request.AvatarUrl);
return Ok();
}
[HttpGet("{userId:guid}")]
[ProducesResponseType(typeof(UserResponse), 200)]
[ProducesResponseType(404)]
public async Task<IActionResult> GetById(Guid userId)
=> Ok(await userService.GetByIdAsync(userId));
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<UserResponse>), 200)]
public async Task<IActionResult> Search(
[FromQuery] string? q,
[FromQuery] Guid? tenantId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
=> Ok(await userService.SearchAsync(q, tenantId, page, pageSize));
[HttpPost("{userId:guid}/ban")]
[ProducesResponseType(204)]
public async Task<IActionResult> Ban(Guid userId, [FromBody] BanUserRequest request)
{
await userService.BanAsync(userId, request.Reason, request.UnblockDate);
return NoContent();
}
private Guid GetUserId() => Guid.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException());
}
@@ -0,0 +1,165 @@
using FlatRender.IdentitySvc.Domain.Enums;
namespace FlatRender.IdentitySvc.Domain.Entities;
public class Plan
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public PlanScope Scope { get; set; } = PlanScope.User;
public string Code { get; set; } = default!;
public string Name { get; set; } = default!;
public string? Description { get; set; }
public long PriceMinor { get; set; }
public long? BeforePriceMinor { get; set; }
public string Currency { get; set; } = "IRR";
public decimal DiscountPercentage { get; set; }
public BillingPeriod BillingPeriod { get; set; } = BillingPeriod.Monthly;
public int? MonthsDuration { get; set; }
public int SecondsCharge { get; set; }
public int? MonthlyRendersQuota { get; set; }
public int StorageGb { get; set; } = 1;
public int ParallelRenders { get; set; } = 1;
public string MaxResolution { get; set; } = "FullHD";
public int MinVideoLengthSec { get; set; }
public decimal RenderSpeedFactor { get; set; } = 1.0m;
public int Sort { get; set; }
public string? Icon { get; set; }
public string? Cover { get; set; }
public string? Color { get; set; }
public bool IsFeatured { get; set; }
public string Features { get; set; } = "{}";
public bool IsActive { get; set; } = true;
public DateTime? AvailableFrom { get; set; }
public DateTime? AvailableUntil { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
public ICollection<UserPlan> UserPlans { get; set; } = [];
}
public class UserPlan
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public User User { get; set; } = default!;
public Guid TenantId { get; set; }
public Guid PlanId { get; set; }
public Plan Plan { get; set; } = default!;
public string PlanCode { get; set; } = default!;
public string PlanName { get; set; } = default!;
public long PriceMinorPaid { get; set; }
public string Currency { get; set; } = "IRR";
public int InitialSecondsCharge { get; set; }
public int RemainChargeSec { get; set; }
public int AddedChargeFromPastPlan { get; set; }
public int MonthlyRendersUsed { get; set; }
public DateTime? MonthlyRendersResetAt { get; set; }
public DateTime RegisterDate { get; set; } = DateTime.UtcNow;
public DateTime StartsAt { get; set; } = DateTime.UtcNow;
public DateTime ExpiresAt { get; set; }
public DateTime? CancelledAt { get; set; }
public string? CancelReason { get; set; }
public bool AutoRenew { get; set; }
public Guid? PaymentId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class Payment
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
public User User { get; set; } = default!;
public PaymentGateway Gateway { get; set; }
public PaymentStatus Status { get; set; } = PaymentStatus.Pending;
public PaymentAction Action { get; set; }
public long AmountMinor { get; set; }
public string Currency { get; set; } = "IRR";
public long BalanceReducerMinor { get; set; }
public long DiscountValueMinor { get; set; }
public string? GatewayToken { get; set; }
public string? GatewayOrderId { get; set; }
public string? GatewayTrackId { get; set; }
public string? GatewayResponse { get; set; }
public string? CardLast4 { get; set; }
public string? CardHash { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
public Guid? OwnerUserId { get; set; }
public long AffiliateProfitMinor { get; set; }
public Guid? UsedDiscountId { get; set; }
public Guid? PlanId { get; set; }
public Guid? RenderJobId { get; set; }
public Guid? UserProjectId { get; set; }
public DateTime? ConfirmedAt { get; set; }
public DateTime? FailedAt { get; set; }
public string? FailureReason { get; set; }
public DateTime? RefundedAt { get; set; }
public long? RefundAmountMinor { get; set; }
public string? RefundReason { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class Discount
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public string Name { get; set; } = default!;
public string Code { get; set; } = default!;
public DiscountKind Kind { get; set; }
public decimal Value { get; set; }
public Guid? OwnerUserId { get; set; }
public decimal OwnerProfitPercentage { get; set; }
public bool OnlyOwner { get; set; }
public int? MaxUseCount { get; set; }
public int UsedCount { get; set; }
public long MinPurchaseMinor { get; set; }
public Guid[]? AppliesToPlanIds { get; set; }
public DateTime? StartsAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class UsedDiscount
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid DiscountId { get; set; }
public Guid UserId { get; set; }
public Guid? PaymentId { get; set; }
public string Code { get; set; } = default!;
public long AmountDiscountedMinor { get; set; }
public DateTime UseDate { get; set; } = DateTime.UtcNow;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,107 @@
using FlatRender.IdentitySvc.Domain.Enums;
namespace FlatRender.IdentitySvc.Domain.Entities;
public class Quest
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public string Title { get; set; } = default!;
public string? Challenge { get; set; }
public string? Why { get; set; }
public string? Hint { get; set; }
public string? Aphorism { get; set; }
public string? Icon { get; set; }
public QuestType QuestType { get; set; }
public string TargetEvent { get; set; } = default!;
public int TargetCount { get; set; } = 1;
public string Metadata { get; set; } = "{}";
public PrizeType PrizeType { get; set; }
public long PrizeAmount { get; set; }
public int? LevelLimit { get; set; }
public string? StartUrl { get; set; }
public string? PostActionName { get; set; }
public int OrderValue { get; set; }
public DateTime? StartsAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<UserQuestProgress> Progress { get; set; } = [];
}
public class UserQuestProgress
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid QuestId { get; set; }
public Quest Quest { get; set; } = default!;
public int CurrentCount { get; set; }
public string? TextValue { get; set; }
public bool IsCompleted { get; set; }
public DateTime? CompletedAt { get; set; }
public bool PrizeClaimed { get; set; }
public DateTime? PrizeClaimedAt { get; set; }
public DateOnly? PeriodStart { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class Gift
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TenantId { get; set; }
public string Name { get; set; } = default!;
public string? Description { get; set; }
public string? Icon { get; set; }
public GiftType GiftType { get; set; }
public PrizeType PrizeType { get; set; }
public long Value { get; set; }
public string? Unit { get; set; }
public Guid? AssignedByUserId { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<EarnedGift> EarnedGifts { get; set; } = [];
}
public class EarnedGift
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public Guid GiftId { get; set; }
public Gift Gift { get; set; } = default!;
public Guid? NotificationId { get; set; }
public string? Source { get; set; }
public Guid? SourceRef { get; set; }
public DateTime EarnedAt { get; set; } = DateTime.UtcNow;
public DateTime? ExpiresAt { get; set; }
public bool IsUsed { get; set; }
public DateTime? UsedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class Avatar
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Code { get; set; } = default!;
public string Url { get; set; } = default!;
public string? Description { get; set; }
public int Sort { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,157 @@
using FlatRender.IdentitySvc.Domain.Enums;
namespace FlatRender.IdentitySvc.Domain.Entities;
public class Tenant
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Slug { get; set; } = default!;
public string Name { get; set; } = default!;
public TenantKind Kind { get; set; } = TenantKind.Reseller;
public TenantStatus Status { get; set; } = TenantStatus.Trial;
public string? CustomDomain { get; set; }
public bool DomainVerified { get; set; }
public string[] AllowedOrigins { get; set; } = [];
public string? ContactName { get; set; }
public string? ContactEmail { get; set; }
public string? ContactPhone { get; set; }
public string? BillingEmail { get; set; }
public int? MaxUsers { get; set; }
public int? MaxStorageGb { get; set; }
public int? MonthlyRenderQty { get; set; }
public int? MonthlyRenderSec { get; set; }
public DateTime? TrialEndsAt { get; set; }
public DateTime? SuspendedAt { get; set; }
public string? SuspensionReason { get; set; }
public string Metadata { get; set; } = "{}";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
public TenantBranding? Branding { get; set; }
public ICollection<TenantApiKey> ApiKeys { get; set; } = [];
public ICollection<TenantWebhook> Webhooks { get; set; } = [];
}
public class TenantBranding
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public Tenant Tenant { get; set; } = default!;
public string? DisplayName { get; set; }
public string? LogoUrl { get; set; }
public string? LogoDarkUrl { get; set; }
public string? FaviconUrl { get; set; }
public string? OgImageUrl { get; set; }
public string? PrimaryColor { get; set; }
public string? SecondaryColor { get; set; }
public string? AccentColor { get; set; }
public string? BackgroundColor { get; set; }
public string? FontFamily { get; set; }
public string? EmailFromName { get; set; }
public string? EmailFromAddress { get; set; }
public string? EmailReplyTo { get; set; }
public string? EmailFooterHtml { get; set; }
public string? SupportUrl { get; set; }
public string? TermsUrl { get; set; }
public string? PrivacyUrl { get; set; }
public bool EmbedEnabled { get; set; }
public string[] EmbedAllowedHosts { get; set; } = [];
public string? WatermarkText { get; set; }
public string? WatermarkImageUrl { get; set; }
public bool WatermarkEnabled { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class TenantApiKey
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public Tenant Tenant { get; set; } = default!;
public Guid? CreatedByUserId { get; set; }
public string Name { get; set; } = default!;
public string Environment { get; set; } = "Live";
public string KeyPrefix { get; set; } = default!;
public string KeyHash { get; set; } = default!;
public string Last4 { get; set; } = default!;
public string[] Scopes { get; set; } = [];
public string[] AllowedIps { get; set; } = [];
public int RateLimitRpm { get; set; } = 60;
public bool IsActive { get; set; } = true;
public DateTime? ExpiresAt { get; set; }
public DateTime? LastUsedAt { get; set; }
public long UsageCount { get; set; }
public string? RevokeReason { get; set; }
public DateTime? RevokedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class TenantWebhook
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public Tenant Tenant { get; set; } = default!;
public string Name { get; set; } = default!;
public string Url { get; set; } = default!;
public string[] Events { get; set; } = [];
public string? SecretHash { get; set; }
public bool IsActive { get; set; } = true;
public DateTime? LastTriggeredAt { get; set; }
public int? LastStatusCode { get; set; }
public int ConsecutiveFailures { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<TenantWebhookDelivery> Deliveries { get; set; } = [];
}
public class TenantWebhookDelivery
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid WebhookId { get; set; }
public TenantWebhook Webhook { get; set; } = default!;
public string EventType { get; set; } = default!;
public string RequestUrl { get; set; } = default!;
public string? RequestBody { get; set; }
public int? ResponseStatus { get; set; }
public string? ResponseBody { get; set; }
public int DurationMs { get; set; }
public int Attempt { get; set; } = 1;
public bool Succeeded { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? DeliveredAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class TenantUsageDaily
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public DateOnly UsageDate { get; set; }
public int RendersCompleted { get; set; }
public long RenderSeconds { get; set; }
public long StorageBytes { get; set; }
public long ApiCalls { get; set; }
public int ActiveUsers { get; set; }
public long AmountBilledMinor { get; set; }
public string BillingCurrency { get; set; } = "IRR";
public string? BillingStatus { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,172 @@
using FlatRender.IdentitySvc.Domain.Enums;
namespace FlatRender.IdentitySvc.Domain.Entities;
public class User
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TenantId { get; set; }
public Tenant Tenant { get; set; } = default!;
// Auth
public string? Email { get; set; }
public bool EmailVerified { get; set; }
public DateTime? EmailVerifiedAt { get; set; }
public string? PhoneNumber { get; set; }
public string? PhoneCountryCode { get; set; }
public bool PhoneVerified { get; set; }
public DateTime? PhoneVerifiedAt { get; set; }
public string? PasswordHash { get; set; }
public DateTime? PasswordSetAt { get; set; }
public DateTime? LastPasswordResetDate { get; set; }
public RegisterMode RegisterMode { get; set; } = RegisterMode.Email;
public string? ExternalProvider { get; set; }
public string? ExternalProviderId { get; set; }
// Profile
public string? FullName { get; set; }
public string? AvatarUrl { get; set; }
public DateOnly? BirthDate { get; set; }
public GenderKind? Gender { get; set; }
public string? NationalCode { get; set; }
public string? CountryCode { get; set; }
public string? CompanyName { get; set; }
public string? WebsiteName { get; set; }
public string? Slogan { get; set; }
public string? AboutMe { get; set; }
public string? MethodOfIntroduction { get; set; }
// Balances
public long BalanceMinor { get; set; }
public long AffiliateBalanceMinor { get; set; }
public Guid? AffiliateOwnerId { get; set; }
public decimal ProfitPercentage { get; set; }
// Gamification
public int LoyaltyScore { get; set; }
public int PurplePoint { get; set; }
// Render quotas
public int DailyRemainRenderCount { get; set; }
public int MaxDailyRenderCount { get; set; }
public int ParallelRenderingCeiling { get; set; } = 1;
public int UserDailyFreeChargeSec { get; set; }
public DateTime? DailyFreeChargeResetDate { get; set; }
public int MaxPreviewDurationSec { get; set; } = 30;
public bool ForceRenderQueue { get; set; }
public bool RemoveWatermarkService { get; set; }
// Telegram
public string? TelegramId { get; set; }
public bool TelegramTellMe { get; set; }
// Comms prefs
public bool EmailTellMe { get; set; } = true;
public bool SmsTellMe { get; set; }
public bool PushTellMe { get; set; } = true;
// Storage
public string? StorageEndpoint { get; set; }
public long UsedStorageBytes { get; set; }
// Status
public bool IsAdmin { get; set; }
public bool IsTenantAdmin { get; set; }
public bool BanAccount { get; set; }
public string? BanReason { get; set; }
public DateTime? UnblockDate { get; set; }
// Activity
public DateTime? LastActiveDate { get; set; }
public DateTime? LastLoginAt { get; set; }
public string? LastLoginIp { get; set; }
public bool RegisteredWithMobileApp { get; set; }
public DateTime RegisterDate { get; set; } = DateTime.UtcNow;
public string Metadata { get; set; } = "{}";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? DeletedAt { get; set; }
public ICollection<UserSession> Sessions { get; set; } = [];
public ICollection<MfaFactor> MfaFactors { get; set; } = [];
public ICollection<PushSubscription> PushSubscriptions { get; set; } = [];
public ICollection<UserPlan> Plans { get; set; } = [];
public ICollection<Payment> Payments { get; set; } = [];
}
public class UserSession
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public User User { get; set; } = default!;
public Guid TenantId { get; set; }
public string RefreshTokenHash { get; set; } = default!;
public string? DeviceId { get; set; }
public string? DeviceName { get; set; }
public string? UserAgent { get; set; }
public string? IpAddress { get; set; }
public DateTime IssuedAt { get; set; } = DateTime.UtcNow;
public DateTime ExpiresAt { get; set; }
public DateTime? RevokedAt { get; set; }
public DateTime? LastUsedAt { get; set; }
}
public class ConfirmationToken
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? UserId { get; set; }
public Guid TenantId { get; set; }
public TokenPurpose Purpose { get; set; }
public string Identifier { get; set; } = default!;
public string? NextIdentifier { get; set; }
public string TokenHash { get; set; } = default!;
public string? Code { get; set; }
public bool IsConsumed { get; set; }
public DateTime? ConsumedAt { get; set; }
public int TryCount { get; set; }
public int MaxTries { get; set; } = 5;
public string? RequestIp { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class PushSubscription
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public User User { get; set; } = default!;
public Guid TenantId { get; set; }
public string Endpoint { get; set; } = default!;
public string P256dhKey { get; set; } = default!;
public string AuthKey { get; set; } = default!;
public string? UserAgent { get; set; }
public bool IsActive { get; set; } = true;
public DateTime? LastUsedAt { get; set; }
public int FailureCount { get; set; }
public DateTime? LastFailureAt { get; set; }
public int? LastFailureStatus { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class MfaFactor
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public User User { get; set; } = default!;
public MfaFactorType FactorType { get; set; }
public string? SecretEncrypted { get; set; }
public bool IsVerified { get; set; }
public bool IsPrimary { get; set; }
public string? Label { get; set; }
public DateTime? LastUsedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,18 @@
namespace FlatRender.IdentitySvc.Domain.Enums;
public enum TenantStatus { Active, Trial, Suspended, Cancelled }
public enum TenantKind { Internal, Reseller, Enterprise }
public enum RegisterMode { Email, Mobile, Google, Telegram, SSO, Reseller }
public enum GenderKind { Male, Female, Other, PreferNotToSay }
public enum TokenPurpose { EmailVerification, PhoneVerification, PasswordReset, MfaSetup, Login, EmailChange }
public enum MfaFactorType { TOTP, SMS, Email, RecoveryCode }
public enum PlanScope { User, Tenant }
public enum BillingPeriod { Monthly, Quarterly, SemiAnnual, Annual, Lifetime, OneTime }
public enum PaymentGateway { ZarinPal, IdPay, Bazaar, Stripe, Balance, Manual, Tara, SnapPay }
public enum PaymentStatus { Pending, Succeeded, Failed, Refunded, Cancelled }
public enum PaymentAction { PlanPurchase, BalanceCharge, ProjectRender, UserProject, StorageUpgrade, Other }
public enum DiscountKind { Percentage, FixedAmount, FreeMonths, RenderCredits }
public enum UserProjectStatus { Draft, Submitted, Quoted, InProgress, Review, Completed, Cancelled }
public enum QuestType { OneTime, Daily, Weekly, Onboarding, Milestone }
public enum PrizeType { Balance, RenderSeconds, LoyaltyPoints, StorageGB, Plan, Discount }
public enum GiftType { Bonus, Referral, Compensation, Promotion, Achievement }
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.*" />
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
<PackageReference Include="StackExchange.Redis" Version="2.13.17" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.*" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@FlatRender.IdentitySvc_HostAddress = http://localhost:5217
GET {{FlatRender.IdentitySvc_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,599 @@
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Infrastructure.Data;
public class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : DbContext(options)
{
// Tenants
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<TenantBranding> TenantBrandings => Set<TenantBranding>();
public DbSet<TenantApiKey> TenantApiKeys => Set<TenantApiKey>();
public DbSet<TenantWebhook> TenantWebhooks => Set<TenantWebhook>();
public DbSet<TenantWebhookDelivery> TenantWebhookDeliveries => Set<TenantWebhookDelivery>();
public DbSet<TenantUsageDaily> TenantUsageDailies => Set<TenantUsageDaily>();
// Users
public DbSet<User> Users => Set<User>();
public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<ConfirmationToken> ConfirmationTokens => Set<ConfirmationToken>();
public DbSet<PushSubscription> PushSubscriptions => Set<PushSubscription>();
public DbSet<MfaFactor> MfaFactors => Set<MfaFactor>();
// Billing
public DbSet<Plan> Plans => Set<Plan>();
public DbSet<UserPlan> UserPlans => Set<UserPlan>();
public DbSet<Payment> Payments => Set<Payment>();
public DbSet<Discount> Discounts => Set<Discount>();
public DbSet<UsedDiscount> UsedDiscounts => Set<UsedDiscount>();
// Gamification
public DbSet<Quest> Quests => Set<Quest>();
public DbSet<UserQuestProgress> UserQuestProgresses => Set<UserQuestProgress>();
public DbSet<Gift> Gifts => Set<Gift>();
public DbSet<EarnedGift> EarnedGifts => Set<EarnedGift>();
public DbSet<Avatar> Avatars => Set<Avatar>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.HasDefaultSchema("identity");
// Native PostgreSQL enums are registered on the EF provider via npgsql.MapEnum<T>()
// in Program.cs (the EF Core 9+ approach), which covers both the model and the
// runtime ADO type mapping. No HasPostgresEnum<T>() calls are needed here.
ConfigureTenants(mb);
ConfigureUsers(mb);
ConfigureBilling(mb);
ConfigureGamification(mb);
}
private static void ConfigureTenants(ModelBuilder mb)
{
mb.Entity<Tenant>(e =>
{
e.ToTable("tenants");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Slug).HasColumnName("slug").HasColumnType("citext").IsRequired();
e.Property(x => x.Name).HasColumnName("name").IsRequired();
e.Property(x => x.Kind).HasColumnName("kind");
e.Property(x => x.Status).HasColumnName("status");
e.Property(x => x.CustomDomain).HasColumnName("custom_domain").HasColumnType("citext");
e.Property(x => x.DomainVerified).HasColumnName("domain_verified");
e.Property(x => x.AllowedOrigins).HasColumnName("allowed_origins");
e.Property(x => x.ContactName).HasColumnName("contact_name");
e.Property(x => x.ContactEmail).HasColumnName("contact_email").HasColumnType("citext");
e.Property(x => x.ContactPhone).HasColumnName("contact_phone");
e.Property(x => x.BillingEmail).HasColumnName("billing_email").HasColumnType("citext");
e.Property(x => x.MaxUsers).HasColumnName("max_users");
e.Property(x => x.MaxStorageGb).HasColumnName("max_storage_gb");
e.Property(x => x.MonthlyRenderQty).HasColumnName("monthly_render_qty");
e.Property(x => x.MonthlyRenderSec).HasColumnName("monthly_render_sec");
e.Property(x => x.TrialEndsAt).HasColumnName("trial_ends_at");
e.Property(x => x.SuspendedAt).HasColumnName("suspended_at");
e.Property(x => x.SuspensionReason).HasColumnName("suspension_reason");
e.Property(x => x.Metadata).HasColumnName("metadata").HasColumnType("jsonb");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasIndex(x => x.Slug).IsUnique();
e.HasOne(x => x.Branding).WithOne(b => b.Tenant).HasForeignKey<TenantBranding>(b => b.TenantId);
e.HasMany(x => x.ApiKeys).WithOne(k => k.Tenant).HasForeignKey(k => k.TenantId);
e.HasMany(x => x.Webhooks).WithOne(w => w.Tenant).HasForeignKey(w => w.TenantId);
});
mb.Entity<TenantBranding>(e =>
{
e.ToTable("tenant_branding");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.DisplayName).HasColumnName("display_name");
e.Property(x => x.LogoUrl).HasColumnName("logo_url");
e.Property(x => x.LogoDarkUrl).HasColumnName("logo_dark_url");
e.Property(x => x.FaviconUrl).HasColumnName("favicon_url");
e.Property(x => x.OgImageUrl).HasColumnName("og_image_url");
e.Property(x => x.PrimaryColor).HasColumnName("primary_color");
e.Property(x => x.SecondaryColor).HasColumnName("secondary_color");
e.Property(x => x.AccentColor).HasColumnName("accent_color");
e.Property(x => x.BackgroundColor).HasColumnName("background_color");
e.Property(x => x.FontFamily).HasColumnName("font_family");
e.Property(x => x.EmailFromName).HasColumnName("email_from_name");
e.Property(x => x.EmailFromAddress).HasColumnName("email_from_address");
e.Property(x => x.EmailReplyTo).HasColumnName("email_reply_to");
e.Property(x => x.EmailFooterHtml).HasColumnName("email_footer_html");
e.Property(x => x.SupportUrl).HasColumnName("support_url");
e.Property(x => x.TermsUrl).HasColumnName("terms_url");
e.Property(x => x.PrivacyUrl).HasColumnName("privacy_url");
e.Property(x => x.EmbedEnabled).HasColumnName("embed_enabled");
e.Property(x => x.EmbedAllowedHosts).HasColumnName("embed_allowed_hosts");
e.Property(x => x.WatermarkText).HasColumnName("watermark_text");
e.Property(x => x.WatermarkImageUrl).HasColumnName("watermark_image_url");
e.Property(x => x.WatermarkEnabled).HasColumnName("watermark_enabled");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
mb.Entity<TenantApiKey>(e =>
{
e.ToTable("tenant_api_keys");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.CreatedByUserId).HasColumnName("created_by_user_id");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.Environment).HasColumnName("environment");
e.Property(x => x.KeyPrefix).HasColumnName("key_prefix");
e.Property(x => x.KeyHash).HasColumnName("key_hash");
e.Property(x => x.Last4).HasColumnName("last4");
e.Property(x => x.Scopes).HasColumnName("scopes");
e.Property(x => x.AllowedIps).HasColumnName("allowed_ips");
e.Property(x => x.RateLimitRpm).HasColumnName("rate_limit_rpm");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.ExpiresAt).HasColumnName("expires_at");
e.Property(x => x.LastUsedAt).HasColumnName("last_used_at");
e.Property(x => x.UsageCount).HasColumnName("usage_count");
e.Property(x => x.RevokeReason).HasColumnName("revoke_reason");
e.Property(x => x.RevokedAt).HasColumnName("revoked_at");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
});
mb.Entity<TenantWebhook>(e =>
{
e.ToTable("tenant_webhooks");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.Url).HasColumnName("url");
e.Property(x => x.Events).HasColumnName("events");
e.Property(x => x.SecretHash).HasColumnName("secret_hash");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.LastTriggeredAt).HasColumnName("last_triggered_at");
e.Property(x => x.LastStatusCode).HasColumnName("last_status_code");
e.Property(x => x.ConsecutiveFailures).HasColumnName("consecutive_failures");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasMany(x => x.Deliveries).WithOne(d => d.Webhook).HasForeignKey(d => d.WebhookId);
});
mb.Entity<TenantWebhookDelivery>(e =>
{
e.ToTable("tenant_webhook_deliveries");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.WebhookId).HasColumnName("webhook_id");
e.Property(x => x.EventType).HasColumnName("event_type");
e.Property(x => x.RequestUrl).HasColumnName("request_url");
e.Property(x => x.RequestBody).HasColumnName("request_body");
e.Property(x => x.ResponseStatus).HasColumnName("response_status");
e.Property(x => x.ResponseBody).HasColumnName("response_body");
e.Property(x => x.DurationMs).HasColumnName("duration_ms");
e.Property(x => x.Attempt).HasColumnName("attempt");
e.Property(x => x.Succeeded).HasColumnName("succeeded");
e.Property(x => x.ErrorMessage).HasColumnName("error_message");
e.Property(x => x.DeliveredAt).HasColumnName("delivered_at");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
});
mb.Entity<TenantUsageDaily>(e =>
{
e.ToTable("tenant_usage_daily");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.UsageDate).HasColumnName("usage_date");
e.Property(x => x.RendersCompleted).HasColumnName("renders_completed");
e.Property(x => x.RenderSeconds).HasColumnName("render_seconds");
e.Property(x => x.StorageBytes).HasColumnName("storage_bytes");
e.Property(x => x.ApiCalls).HasColumnName("api_calls");
e.Property(x => x.ActiveUsers).HasColumnName("active_users");
e.Property(x => x.AmountBilledMinor).HasColumnName("amount_billed_minor");
e.Property(x => x.BillingCurrency).HasColumnName("billing_currency");
e.Property(x => x.BillingStatus).HasColumnName("billing_status");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.HasIndex(x => new { x.TenantId, x.UsageDate }).IsUnique();
});
}
private static void ConfigureUsers(ModelBuilder mb)
{
mb.Entity<User>(e =>
{
e.ToTable("users");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Email).HasColumnName("email").HasColumnType("citext");
e.Property(x => x.EmailVerified).HasColumnName("email_verified");
e.Property(x => x.EmailVerifiedAt).HasColumnName("email_verified_at");
e.Property(x => x.PhoneNumber).HasColumnName("phone_number");
e.Property(x => x.PhoneCountryCode).HasColumnName("phone_country_code");
e.Property(x => x.PhoneVerified).HasColumnName("phone_verified");
e.Property(x => x.PhoneVerifiedAt).HasColumnName("phone_verified_at");
e.Property(x => x.PasswordHash).HasColumnName("password_hash");
e.Property(x => x.PasswordSetAt).HasColumnName("password_set_at");
e.Property(x => x.LastPasswordResetDate).HasColumnName("last_password_reset_date");
e.Property(x => x.RegisterMode).HasColumnName("register_mode");
e.Property(x => x.ExternalProvider).HasColumnName("external_provider");
e.Property(x => x.ExternalProviderId).HasColumnName("external_provider_id");
e.Property(x => x.FullName).HasColumnName("full_name");
e.Property(x => x.AvatarUrl).HasColumnName("avatar_url");
e.Property(x => x.BirthDate).HasColumnName("birth_date");
e.Property(x => x.Gender).HasColumnName("gender");
e.Property(x => x.NationalCode).HasColumnName("national_code");
e.Property(x => x.CountryCode).HasColumnName("country_code");
e.Property(x => x.CompanyName).HasColumnName("company_name");
e.Property(x => x.WebsiteName).HasColumnName("website_name");
e.Property(x => x.Slogan).HasColumnName("slogan");
e.Property(x => x.AboutMe).HasColumnName("about_me");
e.Property(x => x.MethodOfIntroduction).HasColumnName("method_of_introduction");
e.Property(x => x.BalanceMinor).HasColumnName("balance_minor");
e.Property(x => x.AffiliateBalanceMinor).HasColumnName("affiliate_balance_minor");
e.Property(x => x.AffiliateOwnerId).HasColumnName("affiliate_owner_id");
e.Property(x => x.ProfitPercentage).HasColumnName("profit_percentage");
e.Property(x => x.LoyaltyScore).HasColumnName("loyalty_score");
e.Property(x => x.PurplePoint).HasColumnName("purple_point");
e.Property(x => x.DailyRemainRenderCount).HasColumnName("daily_remain_render_count");
e.Property(x => x.MaxDailyRenderCount).HasColumnName("max_daily_render_count");
e.Property(x => x.ParallelRenderingCeiling).HasColumnName("parallel_rendering_ceiling");
e.Property(x => x.UserDailyFreeChargeSec).HasColumnName("user_daily_free_charge_sec");
e.Property(x => x.DailyFreeChargeResetDate).HasColumnName("daily_free_charge_reset_date");
e.Property(x => x.MaxPreviewDurationSec).HasColumnName("max_preview_duration_sec");
e.Property(x => x.ForceRenderQueue).HasColumnName("force_render_queue");
e.Property(x => x.RemoveWatermarkService).HasColumnName("remove_watermark_service");
e.Property(x => x.TelegramId).HasColumnName("telegram_id");
e.Property(x => x.TelegramTellMe).HasColumnName("telegram_tell_me");
e.Property(x => x.EmailTellMe).HasColumnName("email_tell_me");
e.Property(x => x.SmsTellMe).HasColumnName("sms_tell_me");
e.Property(x => x.PushTellMe).HasColumnName("push_tell_me");
e.Property(x => x.StorageEndpoint).HasColumnName("storage_endpoint");
e.Property(x => x.UsedStorageBytes).HasColumnName("used_storage_bytes");
e.Property(x => x.IsAdmin).HasColumnName("is_admin");
e.Property(x => x.IsTenantAdmin).HasColumnName("is_tenant_admin");
e.Property(x => x.BanAccount).HasColumnName("ban_account");
e.Property(x => x.BanReason).HasColumnName("ban_reason");
e.Property(x => x.UnblockDate).HasColumnName("unblock_date");
e.Property(x => x.LastActiveDate).HasColumnName("last_active_date");
e.Property(x => x.LastLoginAt).HasColumnName("last_login_at");
e.Property(x => x.LastLoginIp).HasColumnName("last_login_ip");
e.Property(x => x.RegisteredWithMobileApp).HasColumnName("registered_with_mobile_app");
e.Property(x => x.RegisterDate).HasColumnName("register_date");
e.Property(x => x.Metadata).HasColumnName("metadata").HasColumnType("jsonb");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
e.HasIndex(x => new { x.TenantId, x.Email }).IsUnique();
e.HasMany(x => x.Sessions).WithOne(s => s.User).HasForeignKey(s => s.UserId).OnDelete(DeleteBehavior.Cascade);
e.HasMany(x => x.MfaFactors).WithOne(m => m.User).HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
e.HasMany(x => x.PushSubscriptions).WithOne(p => p.User).HasForeignKey(p => p.UserId).OnDelete(DeleteBehavior.Cascade);
});
mb.Entity<UserSession>(e =>
{
e.ToTable("user_sessions");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.RefreshTokenHash).HasColumnName("refresh_token_hash");
e.Property(x => x.DeviceId).HasColumnName("device_id");
e.Property(x => x.DeviceName).HasColumnName("device_name");
e.Property(x => x.UserAgent).HasColumnName("user_agent");
e.Property(x => x.IpAddress).HasColumnName("ip_address");
e.Property(x => x.IssuedAt).HasColumnName("issued_at");
e.Property(x => x.ExpiresAt).HasColumnName("expires_at");
e.Property(x => x.RevokedAt).HasColumnName("revoked_at");
e.Property(x => x.LastUsedAt).HasColumnName("last_used_at");
e.HasIndex(x => x.RefreshTokenHash).IsUnique();
});
mb.Entity<ConfirmationToken>(e =>
{
e.ToTable("confirmation_tokens");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Purpose).HasColumnName("purpose");
e.Property(x => x.Identifier).HasColumnName("identifier").HasColumnType("citext");
e.Property(x => x.NextIdentifier).HasColumnName("next_identifier").HasColumnType("citext");
e.Property(x => x.TokenHash).HasColumnName("token_hash");
e.Property(x => x.Code).HasColumnName("code");
e.Property(x => x.IsConsumed).HasColumnName("is_consumed");
e.Property(x => x.ConsumedAt).HasColumnName("consumed_at");
e.Property(x => x.TryCount).HasColumnName("try_count");
e.Property(x => x.MaxTries).HasColumnName("max_tries");
e.Property(x => x.RequestIp).HasColumnName("request_ip");
e.Property(x => x.ExpiresAt).HasColumnName("expires_at");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
});
mb.Entity<PushSubscription>(e =>
{
e.ToTable("push_subscriptions");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Endpoint).HasColumnName("endpoint");
e.Property(x => x.P256dhKey).HasColumnName("p256dh_key");
e.Property(x => x.AuthKey).HasColumnName("auth_key");
e.Property(x => x.UserAgent).HasColumnName("user_agent");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.LastUsedAt).HasColumnName("last_used_at");
e.Property(x => x.FailureCount).HasColumnName("failure_count");
e.Property(x => x.LastFailureAt).HasColumnName("last_failure_at");
e.Property(x => x.LastFailureStatus).HasColumnName("last_failure_status");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.HasIndex(x => new { x.UserId, x.Endpoint }).IsUnique();
});
mb.Entity<MfaFactor>(e =>
{
e.ToTable("mfa_factors");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.FactorType).HasColumnName("factor_type");
e.Property(x => x.SecretEncrypted).HasColumnName("secret_encrypted");
e.Property(x => x.IsVerified).HasColumnName("is_verified");
e.Property(x => x.IsPrimary).HasColumnName("is_primary");
e.Property(x => x.Label).HasColumnName("label");
e.Property(x => x.LastUsedAt).HasColumnName("last_used_at");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
});
}
private static void ConfigureBilling(ModelBuilder mb)
{
mb.Entity<Plan>(e =>
{
e.ToTable("plans");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Scope).HasColumnName("scope");
e.Property(x => x.Code).HasColumnName("code");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.PriceMinor).HasColumnName("price_minor");
e.Property(x => x.BeforePriceMinor).HasColumnName("before_price_minor");
e.Property(x => x.Currency).HasColumnName("currency");
e.Property(x => x.DiscountPercentage).HasColumnName("discount_percentage");
e.Property(x => x.BillingPeriod).HasColumnName("billing_period");
e.Property(x => x.MonthsDuration).HasColumnName("months_duration");
e.Property(x => x.SecondsCharge).HasColumnName("seconds_charge");
e.Property(x => x.MonthlyRendersQuota).HasColumnName("monthly_renders_quota");
e.Property(x => x.StorageGb).HasColumnName("storage_gb");
e.Property(x => x.ParallelRenders).HasColumnName("parallel_renders");
e.Property(x => x.MaxResolution).HasColumnName("max_resolution");
e.Property(x => x.MinVideoLengthSec).HasColumnName("min_video_length_sec");
e.Property(x => x.RenderSpeedFactor).HasColumnName("render_speed_factor");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.Cover).HasColumnName("cover");
e.Property(x => x.Color).HasColumnName("color");
e.Property(x => x.IsFeatured).HasColumnName("is_featured");
e.Property(x => x.Features).HasColumnName("features").HasColumnType("jsonb");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.AvailableFrom).HasColumnName("available_from");
e.Property(x => x.AvailableUntil).HasColumnName("available_until");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.Property(x => x.DeletedAt).HasColumnName("deleted_at");
});
mb.Entity<UserPlan>(e =>
{
e.ToTable("user_plans");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.PlanId).HasColumnName("plan_id");
e.Property(x => x.PlanCode).HasColumnName("plan_code");
e.Property(x => x.PlanName).HasColumnName("plan_name");
e.Property(x => x.PriceMinorPaid).HasColumnName("price_minor_paid");
e.Property(x => x.Currency).HasColumnName("currency");
e.Property(x => x.InitialSecondsCharge).HasColumnName("initial_seconds_charge");
e.Property(x => x.RemainChargeSec).HasColumnName("remain_charge_sec");
e.Property(x => x.AddedChargeFromPastPlan).HasColumnName("added_charge_from_past_plan");
e.Property(x => x.MonthlyRendersUsed).HasColumnName("monthly_renders_used");
e.Property(x => x.MonthlyRendersResetAt).HasColumnName("monthly_renders_reset_at");
e.Property(x => x.RegisterDate).HasColumnName("register_date");
e.Property(x => x.StartsAt).HasColumnName("starts_at");
e.Property(x => x.ExpiresAt).HasColumnName("expires_at");
e.Property(x => x.CancelledAt).HasColumnName("cancelled_at");
e.Property(x => x.CancelReason).HasColumnName("cancel_reason");
e.Property(x => x.AutoRenew).HasColumnName("auto_renew");
e.Property(x => x.PaymentId).HasColumnName("payment_id");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.User).WithMany(u => u.Plans).HasForeignKey(x => x.UserId);
e.HasOne(x => x.Plan).WithMany(p => p.UserPlans).HasForeignKey(x => x.PlanId);
});
mb.Entity<Payment>(e =>
{
e.ToTable("payments");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.Gateway).HasColumnName("gateway");
e.Property(x => x.Status).HasColumnName("status");
e.Property(x => x.Action).HasColumnName("action");
e.Property(x => x.AmountMinor).HasColumnName("amount_minor");
e.Property(x => x.Currency).HasColumnName("currency");
e.Property(x => x.BalanceReducerMinor).HasColumnName("balance_reducer_minor");
e.Property(x => x.DiscountValueMinor).HasColumnName("discount_value_minor");
e.Property(x => x.GatewayToken).HasColumnName("gateway_token");
e.Property(x => x.GatewayOrderId).HasColumnName("gateway_order_id");
e.Property(x => x.GatewayTrackId).HasColumnName("gateway_track_id");
e.Property(x => x.GatewayResponse).HasColumnName("gateway_response").HasColumnType("jsonb");
e.Property(x => x.CardLast4).HasColumnName("card_last4");
e.Property(x => x.CardHash).HasColumnName("card_hash");
e.Property(x => x.Title).HasColumnName("title");
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.OwnerUserId).HasColumnName("owner_user_id");
e.Property(x => x.AffiliateProfitMinor).HasColumnName("affiliate_profit_minor");
e.Property(x => x.UsedDiscountId).HasColumnName("used_discount_id");
e.Property(x => x.PlanId).HasColumnName("plan_id");
e.Property(x => x.RenderJobId).HasColumnName("render_job_id");
e.Property(x => x.UserProjectId).HasColumnName("user_project_id");
e.Property(x => x.ConfirmedAt).HasColumnName("confirmed_at");
e.Property(x => x.FailedAt).HasColumnName("failed_at");
e.Property(x => x.FailureReason).HasColumnName("failure_reason");
e.Property(x => x.RefundedAt).HasColumnName("refunded_at");
e.Property(x => x.RefundAmountMinor).HasColumnName("refund_amount_minor");
e.Property(x => x.RefundReason).HasColumnName("refund_reason");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasOne(x => x.User).WithMany(u => u.Payments).HasForeignKey(x => x.UserId);
});
mb.Entity<Discount>(e =>
{
e.ToTable("discounts");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.Code).HasColumnName("code").HasColumnType("citext");
e.Property(x => x.Kind).HasColumnName("kind");
e.Property(x => x.Value).HasColumnName("value");
e.Property(x => x.OwnerUserId).HasColumnName("owner_user_id");
e.Property(x => x.OwnerProfitPercentage).HasColumnName("owner_profit_percentage");
e.Property(x => x.OnlyOwner).HasColumnName("only_owner");
e.Property(x => x.MaxUseCount).HasColumnName("max_use_count");
e.Property(x => x.UsedCount).HasColumnName("used_count");
e.Property(x => x.MinPurchaseMinor).HasColumnName("min_purchase_minor");
e.Property(x => x.AppliesToPlanIds).HasColumnName("applies_to_plan_ids");
e.Property(x => x.StartsAt).HasColumnName("starts_at");
e.Property(x => x.ExpiresAt).HasColumnName("expires_at");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
});
mb.Entity<UsedDiscount>(e =>
{
e.ToTable("used_discounts");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.DiscountId).HasColumnName("discount_id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.PaymentId).HasColumnName("payment_id");
e.Property(x => x.Code).HasColumnName("code").HasColumnType("citext");
e.Property(x => x.AmountDiscountedMinor).HasColumnName("amount_discounted_minor");
e.Property(x => x.UseDate).HasColumnName("use_date");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
});
}
private static void ConfigureGamification(ModelBuilder mb)
{
mb.Entity<Quest>(e =>
{
e.ToTable("quests");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Title).HasColumnName("title");
e.Property(x => x.Challenge).HasColumnName("challenge");
e.Property(x => x.Why).HasColumnName("why");
e.Property(x => x.Hint).HasColumnName("hint");
e.Property(x => x.Aphorism).HasColumnName("aphorism");
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.QuestType).HasColumnName("quest_type");
e.Property(x => x.TargetEvent).HasColumnName("target_event");
e.Property(x => x.TargetCount).HasColumnName("target_count");
e.Property(x => x.Metadata).HasColumnName("metadata").HasColumnType("jsonb");
e.Property(x => x.PrizeType).HasColumnName("prize_type");
e.Property(x => x.PrizeAmount).HasColumnName("prize_amount");
e.Property(x => x.LevelLimit).HasColumnName("level_limit");
e.Property(x => x.StartUrl).HasColumnName("start_url");
e.Property(x => x.PostActionName).HasColumnName("post_action_name");
e.Property(x => x.OrderValue).HasColumnName("order_value");
e.Property(x => x.StartsAt).HasColumnName("starts_at");
e.Property(x => x.ExpiresAt).HasColumnName("expires_at");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasMany(x => x.Progress).WithOne(p => p.Quest).HasForeignKey(p => p.QuestId);
});
mb.Entity<UserQuestProgress>(e =>
{
e.ToTable("user_quest_progress");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.QuestId).HasColumnName("quest_id");
e.Property(x => x.CurrentCount).HasColumnName("current_count");
e.Property(x => x.TextValue).HasColumnName("text_value");
e.Property(x => x.IsCompleted).HasColumnName("is_completed");
e.Property(x => x.CompletedAt).HasColumnName("completed_at");
e.Property(x => x.PrizeClaimed).HasColumnName("prize_claimed");
e.Property(x => x.PrizeClaimedAt).HasColumnName("prize_claimed_at");
e.Property(x => x.PeriodStart).HasColumnName("period_start");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasIndex(x => new { x.UserId, x.QuestId, x.PeriodStart }).IsUnique();
});
mb.Entity<Gift>(e =>
{
e.ToTable("gifts");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.TenantId).HasColumnName("tenant_id");
e.Property(x => x.Name).HasColumnName("name");
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.Icon).HasColumnName("icon");
e.Property(x => x.GiftType).HasColumnName("gift_type");
e.Property(x => x.PrizeType).HasColumnName("prize_type");
e.Property(x => x.Value).HasColumnName("value");
e.Property(x => x.Unit).HasColumnName("unit");
e.Property(x => x.AssignedByUserId).HasColumnName("assigned_by_user_id");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.HasMany(x => x.EarnedGifts).WithOne(eg => eg.Gift).HasForeignKey(eg => eg.GiftId);
});
mb.Entity<EarnedGift>(e =>
{
e.ToTable("earned_gifts");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.UserId).HasColumnName("user_id");
e.Property(x => x.GiftId).HasColumnName("gift_id");
e.Property(x => x.NotificationId).HasColumnName("notification_id");
e.Property(x => x.Source).HasColumnName("source");
e.Property(x => x.SourceRef).HasColumnName("source_ref");
e.Property(x => x.EarnedAt).HasColumnName("earned_at");
e.Property(x => x.ExpiresAt).HasColumnName("expires_at");
e.Property(x => x.IsUsed).HasColumnName("is_used");
e.Property(x => x.UsedAt).HasColumnName("used_at");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
});
mb.Entity<Avatar>(e =>
{
e.ToTable("avatars");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Code).HasColumnName("code");
e.Property(x => x.Url).HasColumnName("url");
e.Property(x => x.Description).HasColumnName("description");
e.Property(x => x.Sort).HasColumnName("sort");
e.Property(x => x.IsActive).HasColumnName("is_active");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.HasIndex(x => x.Code).IsUnique();
});
}
}
@@ -0,0 +1,20 @@
using Npgsql;
namespace FlatRender.IdentitySvc.Infrastructure.Data;
/// <summary>
/// Npgsql name translator that returns CLR names verbatim.
///
/// The database enum labels are PascalCase (e.g. 'Internal', 'Active', 'ZarinPal')
/// and match the C# enum member names exactly, so no snake_case translation may be
/// applied to enum <em>values</em>. PG type names (e.g. tenant_kind) are still passed
/// explicitly wherever this translator is used, so type-name translation is moot.
/// </summary>
public sealed class PreserveCaseNameTranslator : INpgsqlNameTranslator
{
public static readonly PreserveCaseNameTranslator Instance = new();
public string TranslateTypeName(string clrName) => clrName;
public string TranslateMemberName(string clrName) => clrName;
}
@@ -0,0 +1,49 @@
using System.Net;
using System.Text.Json;
using FlatRender.IdentitySvc.Models.Responses;
namespace FlatRender.IdentitySvc.Middleware;
public class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception ex)
{
var traceId = context.TraceIdentifier;
var (status, code, message) = ex switch
{
KeyNotFoundException => (HttpStatusCode.NotFound, "NOT_FOUND", ex.Message),
UnauthorizedAccessException => (HttpStatusCode.Unauthorized, "UNAUTHORIZED", ex.Message),
InvalidOperationException => (HttpStatusCode.BadRequest, "INVALID_OPERATION", ex.Message),
ArgumentException => (HttpStatusCode.BadRequest, "INVALID_ARGUMENT", ex.Message),
NotImplementedException => (HttpStatusCode.NotImplemented, "NOT_IMPLEMENTED", ex.Message),
_ => (HttpStatusCode.InternalServerError, "INTERNAL_ERROR", "An unexpected error occurred"),
};
if (status == HttpStatusCode.InternalServerError)
logger.LogError(ex, "Unhandled exception {TraceId}", traceId);
context.Response.StatusCode = (int)status;
context.Response.ContentType = "application/json";
var body = JsonSerializer.Serialize(
new { error = new ApiError(code, message, null, traceId) },
JsonOptions);
await context.Response.WriteAsync(body);
}
}
@@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
namespace FlatRender.IdentitySvc.Models.Requests;
public record RegisterRequest(
[Required] string TenantSlug,
string? Email,
string? PhoneNumber,
[Required, MinLength(8)] string Password,
string? FullName,
string? AffiliateCode,
bool AcceptTerms = true
);
public record LoginRequest(
[Required] string TenantSlug,
string? Email,
string? PhoneNumber,
[Required] string Password,
string? DeviceId,
string? DeviceName
);
public record OAuthLoginRequest(
[Required] string TenantSlug,
[Required] string Code,
string? RedirectUri
);
public record RefreshTokenRequest([Required] string RefreshToken);
public record VerifyOtpRequest([Required] string Token, [Required] string Code);
public record PasswordResetRequestDto([Required] string TenantSlug, string? Email, string? PhoneNumber);
public record PasswordResetConfirmRequest([Required] string Token, [Required, MinLength(8)] string NewPassword);
public record PasswordChangeRequest([Required] string CurrentPassword, [Required, MinLength(8)] string NewPassword);
public record MfaSetupRequest([Required] string FactorType, string? Label);
public record MfaVerifyRequest([Required] Guid FactorId, [Required] string Code);
public record MfaChallengeRequest([Required] string MfaToken, [Required] string Code);
public record PushSubscribeRequest(
[Required] string Endpoint,
[Required] PushKeys Keys,
string? UserAgent
);
public record PushKeys([Required] string P256dh, [Required] string Auth);
public record PushUnsubscribeRequest(string? Endpoint);
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace FlatRender.IdentitySvc.Models.Requests;
public record PurchasePlanRequest(
[Required] Guid PlanId,
string? Gateway,
string? DiscountCode
);
public record ValidateDiscountRequest([Required] string Code, Guid? PlanId);
public record CreateDiscountRequest(
[Required] string Name,
[Required] string Code,
[Required] string Kind,
[Required] decimal Value,
Guid? OwnerUserId,
decimal OwnerProfitPercentage = 0,
int? MaxUseCount = null,
Guid[]? AppliesToPlanIds = null,
DateTime? StartsAt = null,
DateTime? ExpiresAt = null
);
public record IssueRefundRequest(
[Required] Guid PaymentId,
long? AmountMinor,
[Required] string Reason,
string RefundTo = "Balance"
);
@@ -0,0 +1,71 @@
using System.ComponentModel.DataAnnotations;
namespace FlatRender.IdentitySvc.Models.Requests;
public record CreateTenantRequest(
[Required] string Slug,
[Required] string Name,
string? Kind,
string? ContactName,
[Required, EmailAddress] string ContactEmail,
string? ContactPhone
);
public record UpdateTenantRequest(
string? Name,
string? ContactName,
string? ContactEmail,
string? ContactPhone,
string? BillingEmail,
string[]? AllowedOrigins
);
public record TenantBrandingRequest(
string? DisplayName,
string? LogoUrl,
string? LogoDarkUrl,
string? FaviconUrl,
string? OgImageUrl,
string? PrimaryColor,
string? SecondaryColor,
string? AccentColor,
string? BackgroundColor,
string? FontFamily,
string? EmailFromName,
string? EmailFromAddress,
string? EmailReplyTo,
string? EmailFooterHtml,
string? SupportUrl,
string? TermsUrl,
string? PrivacyUrl,
bool? EmbedEnabled,
string[]? EmbedAllowedHosts,
string? WatermarkText,
string? WatermarkImageUrl,
bool? WatermarkEnabled
);
public record StartDomainVerificationRequest([Required] string Domain, string Method = "DNS_TXT");
public record CreateApiKeyRequest(
[Required] string Name,
string Environment = "Live",
[Required] string[] Scopes = default!,
string[]? AllowedIps = null,
int RateLimitRpm = 60,
DateTime? ExpiresAt = null
);
public record RevokeApiKeyRequest(string? Reason);
public record ValidateApiKeyRequest(
[Required] string KeyPrefix,
[Required] string KeyHash,
string? IpAddress
);
public record CreateWebhookRequest(
[Required] string Name,
[Required] string Url,
[Required] string[] Events
);
@@ -0,0 +1,19 @@
namespace FlatRender.IdentitySvc.Models.Requests;
public record UpdateUserRequest(
string? FullName,
string? Slogan,
string? AboutMe,
string? CompanyName,
string? WebsiteName,
DateOnly? BirthDate,
string? Gender,
bool? EmailTellMe,
bool? SmsTellMe,
bool? PushTellMe,
bool? TelegramTellMe
);
public record SetAvatarRequest(Guid? AvatarId, string? AvatarUrl);
public record BanUserRequest(string Reason, DateTime? UnblockDate);
@@ -0,0 +1,278 @@
namespace FlatRender.IdentitySvc.Models.Responses;
public record AuthTokensResponse(
string AccessToken,
string RefreshToken,
string TokenType,
int ExpiresIn,
UserResponse? User,
TenantResponse? Tenant
);
public record RegisterResponse(Guid UserId, bool VerificationRequired);
public record SessionResponse(
Guid Id,
string? DeviceName,
string? UserAgent,
string? IpAddress,
DateTime IssuedAt,
DateTime? LastUsedAt,
bool IsCurrent
);
public record MfaSetupResponse(
Guid FactorId,
string? Secret,
string? QrCodeUrl,
string[]? RecoveryCodes
);
public record UserResponse(
Guid Id,
Guid TenantId,
string? Email,
bool EmailVerified,
string? PhoneNumber,
bool PhoneVerified,
string? FullName,
string? AvatarUrl,
bool IsAdmin,
bool IsTenantAdmin,
string RegisterMode,
DateTime? LastActiveDate,
long BalanceMinor,
long AffiliateBalanceMinor,
int LoyaltyScore,
int DailyRemainRenderCount,
int MaxDailyRenderCount,
int ParallelRenderingCeiling,
long UsedStorageBytes,
DateTime RegisterDate
);
public record BalanceResponse(
long BalanceMinor,
long AffiliateBalanceMinor,
string Currency,
int DailyRemainRenderCount,
int ParallelRenderingCeiling
);
public record TenantResponse(
Guid Id,
string Slug,
string Name,
string Kind,
string Status,
string? CustomDomain,
bool DomainVerified,
string? ContactEmail,
int? MaxUsers,
int? MaxStorageGb,
int? MonthlyRenderQty,
DateTime? TrialEndsAt,
DateTime CreatedAt
);
public record TenantBrandingResponse(
Guid TenantId,
string? DisplayName,
string? LogoUrl,
string? LogoDarkUrl,
string? PrimaryColor,
string? SecondaryColor,
string? AccentColor,
bool EmbedEnabled,
bool WatermarkEnabled
);
public record DomainVerificationResponse(
Guid VerificationId,
string ChallengeRecord,
DateTime ExpiresAt
);
public record TenantUsageDayResponse(
DateOnly UsageDate,
int RendersCompleted,
long RenderSeconds,
long StorageBytes,
long ApiCalls,
int ActiveUsers,
long AmountBilledMinor,
string BillingCurrency,
string? BillingStatus
);
public record ApiKeyResponse(
Guid Id,
Guid TenantId,
string Name,
string Environment,
string KeyPrefix,
string Last4,
string[] Scopes,
string[] AllowedIps,
int RateLimitRpm,
bool IsActive,
DateTime? ExpiresAt,
DateTime? LastUsedAt,
long UsageCount,
DateTime CreatedAt
);
public record ApiKeyCreatedResponse(
Guid Id,
Guid TenantId,
string Name,
string Environment,
string KeyPrefix,
string Last4,
string[] Scopes,
string SecretKey,
DateTime CreatedAt
);
public record ApiKeyValidateResponse(
bool Valid,
Guid? TenantId,
string[]? Scopes,
int? RateLimitRpm
);
public record WebhookResponse(
Guid Id,
string Name,
string Url,
string[] Events,
bool IsActive,
DateTime? LastTriggeredAt,
int? LastStatusCode,
int ConsecutiveFailures,
DateTime CreatedAt
);
public record WebhookDeliveryResponse(
Guid Id,
string EventType,
string RequestUrl,
int? ResponseStatus,
string? ResponseBody,
int DurationMs,
int Attempt,
bool Succeeded,
string? ErrorMessage,
DateTime? DeliveredAt,
DateTime CreatedAt
);
public record PlanResponse(
Guid Id,
string Code,
string Name,
string? Description,
long PriceMinor,
long? BeforePriceMinor,
string Currency,
string BillingPeriod,
int SecondsCharge,
int? MonthlyRendersQuota,
int StorageGb,
int ParallelRenders,
string MaxResolution,
decimal RenderSpeedFactor,
string? Icon,
bool IsFeatured,
object Features
);
public record UserPlanResponse(
Guid Id,
Guid PlanId,
string PlanCode,
string PlanName,
int InitialSecondsCharge,
int RemainChargeSec,
int MonthlyRendersUsed,
DateTime StartsAt,
DateTime ExpiresAt,
DateTime? CancelledAt,
bool AutoRenew
);
public record PurchasePlanResponse(Guid PaymentId, string RedirectUrl);
public record PaymentResponse(
Guid Id,
string Gateway,
string Status,
string Action,
long AmountMinor,
string Currency,
string? Title,
string? Description,
string? CardLast4,
DateTime? ConfirmedAt,
DateTime? FailedAt,
string? FailureReason,
DateTime CreatedAt
);
public record RefundResponse(Guid RefundId, string Status);
public record DiscountValidateResponse(
bool Valid,
long DiscountMinor,
string Kind,
decimal Value
);
public record DiscountResponse(
Guid Id,
string Name,
string Code,
string Kind,
decimal Value,
int UsedCount,
int? MaxUseCount,
bool IsActive,
DateTime? ExpiresAt,
DateTime CreatedAt
);
public record QuestResponse(
Guid Id,
string Title,
string? Challenge,
string? Why,
string? Hint,
string? Icon,
string QuestType,
int TargetCount,
int CurrentCount,
bool IsCompleted,
bool PrizeClaimed,
string PrizeType,
long PrizeAmount,
DateTime? ExpiresAt
);
public record EarnedGiftResponse(
Guid Id,
Guid GiftId,
string Name,
string? Description,
string PrizeType,
long Value,
string? Unit,
DateTime EarnedAt,
DateTime? ExpiresAt,
bool IsUsed
);
public record PagedResponse<T>(List<T> Data, PaginationMeta Meta);
public record PaginationMeta(int Page, int PageSize, long Total, bool HasMore);
public record ApiError(string Code, string Message, object? Details = null, string? TraceId = null);
@@ -0,0 +1,174 @@
using System.Text;
using FlatRender.IdentitySvc.Application.Services;
using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Middleware;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Npgsql;
var builder = WebApplication.CreateBuilder(args);
// ── Database ──────────────────────────────────────────────────────────────
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection is required");
// Native PostgreSQL enums are mapped on the EF provider so Npgsql can read/write them
// at runtime (HasPostgresEnum in the model alone is not enough on Npgsql 8+). EF builds
// the data source with these mappings. PG labels are PascalCase and match the C# enum
// member names, so a preserve-case name translator is used for the values.
var enumTr = PreserveCaseNameTranslator.Instance;
builder.Services.AddDbContext<IdentityDbContext>(options =>
options.UseNpgsql(
connectionString,
npgsql =>
{
npgsql.MigrationsHistoryTable("__ef_migrations", "identity");
npgsql.MapEnum<TenantStatus>("tenant_status", "identity", enumTr);
npgsql.MapEnum<TenantKind>("tenant_kind", "identity", enumTr);
npgsql.MapEnum<RegisterMode>("register_mode", "identity", enumTr);
npgsql.MapEnum<GenderKind>("gender_kind", "identity", enumTr);
npgsql.MapEnum<TokenPurpose>("token_purpose", "identity", enumTr);
npgsql.MapEnum<MfaFactorType>("mfa_factor_type", "identity", enumTr);
npgsql.MapEnum<PlanScope>("plan_scope", "identity", enumTr);
npgsql.MapEnum<BillingPeriod>("billing_period", "identity", enumTr);
npgsql.MapEnum<PaymentGateway>("payment_gateway", "identity", enumTr);
npgsql.MapEnum<PaymentStatus>("payment_status", "identity", enumTr);
npgsql.MapEnum<PaymentAction>("payment_action", "identity", enumTr);
npgsql.MapEnum<DiscountKind>("discount_kind", "identity", enumTr);
npgsql.MapEnum<QuestType>("quest_type", "identity", enumTr);
npgsql.MapEnum<PrizeType>("prize_type", "identity", enumTr);
npgsql.MapEnum<GiftType>("gift_type", "identity", enumTr);
}
)
.UseSnakeCaseNamingConvention()
);
// ── JWT Auth ──────────────────────────────────────────────────────────────
var jwtSecret = builder.Configuration["Jwt:Secret"]
?? throw new InvalidOperationException("Jwt:Secret is required");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "flatrender-identity",
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "flatrender",
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
};
});
builder.Services.AddAuthorization();
// ── Services ──────────────────────────────────────────────────────────────
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<IPlanService, PlanService>();
builder.Services.AddScoped<IDiscountService, DiscountService>();
builder.Services.AddScoped<IGamificationService, GamificationService>();
builder.Services.AddScoped<IPaymentService, PaymentService>();
// ── HTTP clients ───────────────────────────────────────────────────────────
builder.Services.AddHttpClient("zarinpal", client =>
{
client.DefaultRequestHeaders.Accept.Add(
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddHttpClient("snappay", client =>
{
client.DefaultRequestHeaders.Accept.Add(
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
client.Timeout = TimeSpan.FromSeconds(15); // SnapPay token exchange can be slow
});
builder.Services.AddHttpClient("tara", client =>
{
client.DefaultRequestHeaders.Accept.Add(
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
client.Timeout = TimeSpan.FromSeconds(10);
});
// ── Routing ───────────────────────────────────────────────────────────────
builder.Services.AddRouting(opts =>
{
opts.LowercaseUrls = true;
opts.AppendTrailingSlash = false;
});
// ── Controllers + Swagger ─────────────────────────────────────────────────
builder.Services.AddControllers()
.AddJsonOptions(o => o.JsonSerializerOptions.PropertyNamingPolicy =
System.Text.Json.JsonNamingPolicy.SnakeCaseLower);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "FlatRender Identity Service", Version = "v1" });
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "JWT Bearer token. Format: Bearer {token}",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer",
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
},
Array.Empty<string>()
}
});
});
builder.Services.AddCors(options =>
options.AddDefaultPolicy(policy =>
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
builder.Services.AddHealthChecks()
.AddCheck("db", () =>
{
// actual DB ping happens at startup migration; just return healthy here
return Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy();
});
var app = builder.Build();
app.UseMiddleware<ExceptionMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
db.Database.Migrate();
}
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5217",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7041;http://localhost:5217",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,19 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=flatrender;Username=svc_identity;Password=YOUR_PASSWORD;SearchPath=identity,public"
},
"Jwt": {
"Secret": "YOUR_JWT_SECRET_AT_LEAST_32_CHARS_LONG_HERE",
"Issuer": "flatrender-identity",
"Audience": "flatrender",
"AccessTokenMinutes": "15",
"RefreshTokenDays": "30"
},
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}
@@ -0,0 +1,20 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": ""
},
"Jwt": {
"Secret": "",
"Issuer": "flatrender-identity",
"Audience": "flatrender",
"AccessTokenMinutes": "15",
"RefreshTokenDays": "30"
}
}
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="http://171.22.25.73:8081/repository/nuget-group/index.json" protocolVersion="3" allowInsecureConnections="true" />
</packageSources>
</configuration>
+38
View File
@@ -0,0 +1,38 @@
version: '3.9'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: flatrender
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ../../backend/db/migrations:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
identity:
build:
context: .
dockerfile: Dockerfile
ports:
- "5010:8080"
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=flatrender;Username=postgres;Password=postgres;SearchPath=identity,public"
Jwt__Secret: "dev-jwt-secret-change-in-production-use-32-chars"
Jwt__Issuer: flatrender-identity
Jwt__Audience: flatrender
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata: