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> 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( tenants.Select(AuthService.MapTenantResponse).ToList(), new PaginationMeta(page, pageSize, total, total > (long)page * pageSize) ); } public async Task 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(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 GetByIdAsync(Guid tenantId) { var tenant = await db.Tenants.FindAsync(tenantId) ?? throw new KeyNotFoundException("Tenant not found"); return AuthService.MapTenantResponse(tenant); } public async Task 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 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 GetBrandingAsync(Guid tenantId) { var branding = await db.TenantBrandings.FirstOrDefaultAsync(b => b.TenantId == tenantId) ?? new TenantBranding { TenantId = tenantId }; return MapBrandingResponse(branding); } public async Task 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 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> 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> 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 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 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> 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 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> 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 ); }