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:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+12
@@ -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();
|
||||
}
|
||||
+17
@@ -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}&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}&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}&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();
|
||||
});
|
||||
}
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user