using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Tiku.Application.Backoffice; using Tiku.Application.PlatformAdmin; using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.PlatformAdmin; internal sealed partial class PlatformGovernanceService( TikuDbContext dbContext, IOperationAuditService auditService) : IPlatformGovernanceService { public async Task> GetConfigurationDefinitionsAsync( PlatformApprovalActor actor, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); return await dbContext.PlatformConfigurationDefinitions.AsNoTracking().OrderBy(item => item.Category) .ThenBy(item => item.Code) .Select(item => ToItem(item)).ToArrayAsync(cancellationToken); } public async Task> GetConfigurationVersionsAsync( PlatformApprovalActor actor, string definitionCode, string? environment, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); var definitionId = await dbContext.PlatformConfigurationDefinitions.AsNoTracking() .Where(item => item.Code == definitionCode).Select(item => (Guid?)item.Id) .SingleOrDefaultAsync(cancellationToken) ?? throw Error("Configuration definition was not found.", "platform_configuration_not_found"); var query = dbContext.PlatformConfigurationVersions.AsNoTracking() .Where(item => item.DefinitionId == definitionId); if (!string.IsNullOrWhiteSpace(environment)) query = query.Where(item => item.Environment == NormalizeEnvironment(environment)); return await query.OrderByDescending(item => item.Version).Select(item => ToItem(item)) .ToArrayAsync(cancellationToken); } public async Task SaveConfigurationDraftAsync( PlatformApprovalActor actor, SavePlatformConfigurationDraftCommand command, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); var definition = await dbContext.PlatformConfigurationDefinitions.SingleOrDefaultAsync( item => item.Code == command.DefinitionCode, cancellationToken) ?? throw Error("Configuration definition was not found.", "platform_configuration_not_found"); if (!definition.AllowRuntimeManagement) throw Error("Security-controlled configuration cannot be changed at runtime.", "platform_configuration_runtime_forbidden"); var environment = NormalizeEnvironment(command.Environment); ValidateValue(definition, command.Value, command.SecretRef); var nextVersion = (await dbContext.PlatformConfigurationVersions .Where(item => item.DefinitionId == definition.Id && item.Environment == environment) .MaxAsync(item => (int?)item.Version, cancellationToken) ?? 0) + 1; var item = new PlatformConfigurationVersion { DefinitionId = definition.Id, Environment = environment, Version = nextVersion, Value = definition.IsSensitive ? JsonDefaults.Object() : command.Value!.Value.Clone(), SecretRef = definition.IsSensitive ? command.SecretRef!.Trim() : null, CreatedBy = actor.UserId, Reason = Required(command.Reason, "reason") }; dbContext.PlatformConfigurationVersions.Add(item); await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.configuration.draft_saved", "platform_configuration_versions", item.Id, new { definition.Code, item.Environment, item.Version }, cancellationToken); return ToItem(item); } public async Task PublishConfigurationAsync( PlatformApprovalActor actor, Guid versionId, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); var item = await dbContext.PlatformConfigurationVersions.SingleOrDefaultAsync(value => value.Id == versionId, cancellationToken) ?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found"); if (item.Status != PlatformConfigurationVersionStatus.Draft) throw Error("Only a draft configuration can be published.", "platform_configuration_not_draft"); var current = await dbContext.PlatformConfigurationVersions.Where(value => value.DefinitionId == item.DefinitionId && value.Environment == item.Environment && value.Status == PlatformConfigurationVersionStatus.Published) .ToArrayAsync(cancellationToken); foreach (var published in current) published.Status = PlatformConfigurationVersionStatus.Retired; item.Status = PlatformConfigurationVersionStatus.Published; item.PublishedBy = actor.UserId; item.PublishedAt = DateTimeOffset.UtcNow; await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.configuration.published", "platform_configuration_versions", item.Id, new { item.DefinitionId, item.Environment, item.Version }, cancellationToken); return ToItem(item); } public async Task RollbackConfigurationAsync( PlatformApprovalActor actor, Guid versionId, string reason, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformConfigurationManage); var source = await dbContext.PlatformConfigurationVersions.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == versionId, cancellationToken) ?? throw Error("Configuration version was not found.", "platform_configuration_version_not_found"); var nextVersion = (await dbContext.PlatformConfigurationVersions.Where(item => item.DefinitionId == source.DefinitionId && item.Environment == source.Environment) .MaxAsync(item => (int?)item.Version, cancellationToken) ?? 0) + 1; var rollback = new PlatformConfigurationVersion { DefinitionId = source.DefinitionId, Environment = source.Environment, Version = nextVersion, Value = source.Value.Clone(), SecretRef = source.SecretRef, CreatedBy = actor.UserId, RolledBackFromVersionId = source.Id, Reason = Required(reason, "reason") }; dbContext.PlatformConfigurationVersions.Add(rollback); await dbContext.SaveChangesAsync(cancellationToken); return await PublishConfigurationAsync(actor, rollback.Id, cancellationToken); } public async Task> GetNotificationDeliveriesAsync( PlatformApprovalActor actor, PagedQuery query, PlatformNotificationDeliveryStatus? status, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); var values = dbContext.PlatformNotificationDeliveries.AsNoTracking(); if (status.HasValue) values = values.Where(item => item.Status == status.Value); if (!string.IsNullOrWhiteSpace(query.Search)) values = values.Where(item => item.Subject.Contains(query.Search) || item.RecipientRoleCode.Contains(query.Search)); var total = await values.CountAsync(cancellationToken); var items = await values.OrderByDescending(item => item.CreatedAt) .Skip((query.SafePage - 1) * query.SafePageSize).Take(query.SafePageSize) .Select(item => ToItem(item)).ToArrayAsync(cancellationToken); return new PagedResult(items, total, query.SafePage, query.SafePageSize); } public async Task> GetNotificationTemplatesAsync( PlatformApprovalActor actor, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); return await dbContext.PlatformNotificationTemplates.AsNoTracking().OrderBy(item => item.Code) .Select(item => ToItem(item)).ToArrayAsync(cancellationToken); } public async Task UpsertNotificationTemplateAsync( PlatformApprovalActor actor, UpsertPlatformNotificationTemplateCommand command, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); var code = Required(command.Code, "code").ToLowerInvariant(); var item = command.Id.HasValue ? await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Id == command.Id, cancellationToken) : await dbContext.PlatformNotificationTemplates.SingleOrDefaultAsync(value => value.Code == code, cancellationToken); item ??= new PlatformNotificationTemplate { Code = code }; if (dbContext.Entry(item).State == EntityState.Detached) dbContext.PlatformNotificationTemplates.Add(item); item.Name = Required(command.Name, "name"); item.Channel = command.Channel; item.SubjectTemplate = Required(command.SubjectTemplate, "subjectTemplate"); item.BodyTemplate = Required(command.BodyTemplate, "bodyTemplate"); item.Enabled = command.Enabled; item.Variables = command.Variables.Clone(); await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.notification_template.upserted", "platform_notification_templates", item.Id, new { item.Code, item.Channel }, cancellationToken); return ToItem(item); } public async Task> SendNotificationAsync( PlatformApprovalActor actor, SendPlatformNotificationCommand command, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); var template = await dbContext.PlatformNotificationTemplates.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == command.TemplateId, cancellationToken) ?? throw Error("Notification template was not found.", "platform_notification_template_not_found"); if (!template.Enabled) throw Error("Notification template is disabled.", "platform_notification_template_disabled"); var roles = command.RoleCodes.Select(value => Required(value, "roleCode")).Distinct(StringComparer.Ordinal) .ToArray(); var recipients = await (from binding in dbContext.PlatformBackendUserRoles.AsNoTracking() join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id where roles.Contains(role.Code) && role.Status == BackendRoleStatus.Active select new { binding.UserId, RoleCode = role.Code }).Distinct().ToArrayAsync(cancellationToken); var subject = Render(template.SubjectTemplate, command.Variables); var body = Render(template.BodyTemplate, command.Variables); var deliveries = new List(); foreach (var recipient in recipients) { var existing = await dbContext.PlatformNotificationDeliveries.AsNoTracking().AnyAsync(item => item.TemplateId == template.Id && item.RecipientUserId == recipient.UserId && item.IdempotencyKey == command.IdempotencyKey, cancellationToken); if (existing) continue; var delivery = new PlatformNotificationDelivery { TemplateId = template.Id, RecipientUserId = recipient.UserId, RecipientRoleCode = recipient.RoleCode, Channel = template.Channel, Subject = subject, Body = body, IdempotencyKey = Required(command.IdempotencyKey, "idempotencyKey"), CreatedBy = actor.UserId, Status = template.Channel == PlatformNotificationChannel.InApp ? PlatformNotificationDeliveryStatus.Sent : PlatformNotificationDeliveryStatus.Pending, SentAt = template.Channel == PlatformNotificationChannel.InApp ? DateTimeOffset.UtcNow : null }; dbContext.PlatformNotificationDeliveries.Add(delivery); deliveries.Add(delivery); } await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.notification.sent", "platform_notification_templates", template.Id, new { template.Code, roles, recipients = deliveries.Count, template.Channel }, cancellationToken); return deliveries.Select(ToItem).ToArray(); } public async Task RetryNotificationAsync( PlatformApprovalActor actor, Guid deliveryId, CancellationToken cancellationToken = default) { Require(actor, BackendPermissions.PlatformNotificationManage); var item = await dbContext.PlatformNotificationDeliveries.SingleOrDefaultAsync(value => value.Id == deliveryId, cancellationToken) ?? throw Error("Notification delivery was not found.", "platform_notification_delivery_not_found"); if (item.Status is not (PlatformNotificationDeliveryStatus.Failed or PlatformNotificationDeliveryStatus.Pending)) throw Error("Only pending or failed notification delivery can be retried.", "platform_notification_not_retryable"); item.Status = PlatformNotificationDeliveryStatus.Pending; item.Attempts++; item.LastError = null; await dbContext.SaveChangesAsync(cancellationToken); await AuditAsync(actor.UserId, "platform.notification.retry_requested", "platform_notification_deliveries", item.Id, new { item.Channel, item.Attempts }, cancellationToken); return ToItem(item); } private Task AuditAsync(Guid actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken) { return auditService.WriteAsync( new BackofficeOperationAuditCommand(null, actor, action, targetType, targetId.ToString("N"), JsonSerializer.SerializeToElement(details)), cancellationToken); } private static void Require(PlatformApprovalActor actor, string permission) { if (!actor.Permissions.Contains(permission)) throw Error("Platform governance access is denied.", "platform_access_denied"); } private static string Required(string? value, string field) { return string.IsNullOrWhiteSpace(value) ? throw Error($"{field} is required.", "required_field") : value.Trim(); } private static string NormalizeEnvironment(string value) { value = Required(value, "environment").ToLowerInvariant(); return EnvironmentPattern().IsMatch(value) ? value : throw Error("Environment code is invalid.", "platform_configuration_environment_invalid"); } private static void ValidateValue(PlatformConfigurationDefinition definition, JsonElement? value, string? secretRef) { if (definition.IsSensitive && string.IsNullOrWhiteSpace(secretRef)) throw Error("Sensitive configuration requires a secret reference.", "platform_configuration_secret_ref_required"); if (definition.IsSensitive && value.HasValue && value.Value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined or JsonValueKind.Object)) throw Error("Sensitive configuration cannot contain a plain value.", "platform_configuration_plain_secret_forbidden"); if (!definition.IsSensitive && !string.IsNullOrWhiteSpace(secretRef)) throw Error("Non-sensitive configuration cannot use a secret reference.", "platform_configuration_secret_ref_invalid"); if (!definition.IsSensitive && !value.HasValue) throw Error("Configuration value is required.", "platform_configuration_value_required"); } private static string Render(string template, IReadOnlyDictionary variables) { return TokenPattern().Replace(template, match => variables.TryGetValue(match.Groups[1].Value, out var value) ? value : throw Error($"Notification variable {match.Groups[1].Value} is missing.", "platform_notification_variable_missing")); } private static PlatformApprovalException Error(string message, string code) { return new PlatformApprovalException(message, code); } private static PlatformConfigurationDefinitionItem ToItem(PlatformConfigurationDefinition item) { return new PlatformConfigurationDefinitionItem(item.Id, item.Code, item.Name, item.Category, item.ValueType, item.AllowRuntimeManagement, item.IsSensitive, item.Description, item.ValidationSchema); } private static PlatformConfigurationVersionItem ToItem(PlatformConfigurationVersion item) { return new PlatformConfigurationVersionItem(item.Id, item.DefinitionId, item.Environment, item.Version, item.Status, item.SecretRef is null ? item.Value : null, item.SecretRef, item.CreatedBy, item.PublishedBy, item.RolledBackFromVersionId, item.Reason, item.PublishedAt, item.CreatedAt); } private static PlatformNotificationTemplateItem ToItem(PlatformNotificationTemplate item) { return new PlatformNotificationTemplateItem(item.Id, item.Code, item.Name, item.Channel, item.SubjectTemplate, item.BodyTemplate, item.Enabled, item.Variables, item.UpdatedAt); } private static PlatformNotificationDeliveryItem ToItem(PlatformNotificationDelivery item) { return new PlatformNotificationDeliveryItem(item.Id, item.TemplateId, item.RecipientUserId, item.RecipientRoleCode, item.Channel, item.Status, item.Subject, item.Body, item.Attempts, item.LastError, item.SentAt, item.CreatedAt); } [GeneratedRegex("^[a-z0-9][a-z0-9._-]{0,79}$")] private static partial Regex EnvironmentPattern(); [GeneratedRegex("\\{\\{([a-zA-Z][a-zA-Z0-9_.-]*)\\}\\}")] private static partial Regex TokenPattern(); }