From 783c1fe1d35f7cb8f23fb63fad26123cbe54aafc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:55:20 +0300 Subject: [PATCH] =?UTF-8?q?Claude=20g=C3=BCncellemesi=20=20SqlQueryManager?= =?UTF-8?q?,=20Notification,=20FileManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ISqlObjectManagerAppService.cs | 15 - .../SqlDataFileDto.cs | 12 - .../SqlExecutionDto.cs | 7 - .../SqlObjectManagerAppService.cs | 152 -------- .../FileManagementAppService.cs | 212 +++++++++-- .../AfterAllMigrationsSqlExecutor.cs | 196 ++++++++++ .../HangfireDbSchemaMigrator.cs | 0 .../{Seeds => Migrations}/HostDataSeeder.cs | 0 .../LanguageDataSeeder.cs | 0 .../ListFormSeeder_Administration.cs | 0 .../ListFormSeeder_DefaultJsons.cs | 0 .../ListFormSeeder_Saas.cs | 0 .../ListFormSeeder_Utils.cs | 0 .../{Seeds => Migrations}/MenuDataSeeder.cs | 0 .../PermissionDataSeeder.cs | 0 .../PlatformIdentityDataSeeder.cs | 0 .../{Seeds => Migrations}/SqlDataSeeder.cs | 20 +- .../{Seeds => Migrations}/WizardDataSeeder.cs | 0 .../Seeds/LanguagesData.json | 18 +- ...ieldsDictionaryJson_SaveDbTableColumns.sql | 0 ...ieldsDictionaryJson_SaveDbTableColumns.sql | 0 .../Sozsoft.Platform.DbMigrator.csproj | 24 ++ .../Data/IAfterAllMigrationsSqlExecutor.cs | 16 + .../Data/PlatformDbMigrationService.cs | 55 ++- .../appsettings.Dev.json | 1 + .../appsettings.Production.json | 1 + configs/deployment/docker-compose-app.yml | 6 + ui/src/components/template/Notification.tsx | 267 +++++++++---- ui/src/services/sql-query-manager.service.ts | 57 +-- ui/src/views/admin/files/FileManager.tsx | 56 ++- .../views/admin/files/components/FileItem.tsx | 2 +- ui/src/views/developerKit/SqlQueryManager.tsx | 352 +----------------- 32 files changed, 764 insertions(+), 705 deletions(-) delete mode 100644 api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/SqlDataFileDto.cs create mode 100644 api/src/Sozsoft.Platform.DbMigrator/Migrations/AfterAllMigrationsSqlExecutor.cs rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/HangfireDbSchemaMigrator.cs (100%) rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/HostDataSeeder.cs (100%) rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/LanguageDataSeeder.cs (100%) rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/ListFormSeeder_Administration.cs (100%) rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/ListFormSeeder_DefaultJsons.cs (100%) rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/ListFormSeeder_Saas.cs (100%) rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/ListFormSeeder_Utils.cs (100%) rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/MenuDataSeeder.cs (100%) rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/PermissionDataSeeder.cs (100%) rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/PlatformIdentityDataSeeder.cs (100%) rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/SqlDataSeeder.cs (87%) rename api/src/Sozsoft.Platform.DbMigrator/{Seeds => Migrations}/WizardDataSeeder.cs (100%) rename api/src/Sozsoft.Platform.DbMigrator/Seeds/PostgresData/HostData/{ => ExecuteData}/Sas_H_ListFormFieldsDictionaryJson_SaveDbTableColumns.sql (100%) rename api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/HostData/{ => ExecuteData}/Sas_H_ListFormFieldsDictionaryJson_SaveDbTableColumns.sql (100%) create mode 100644 api/src/Sozsoft.Platform.Domain/Data/IAfterAllMigrationsSqlExecutor.cs diff --git a/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/ISqlObjectManagerAppService.cs b/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/ISqlObjectManagerAppService.cs index ba46a90e..d514557e 100644 --- a/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/ISqlObjectManagerAppService.cs +++ b/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/ISqlObjectManagerAppService.cs @@ -40,19 +40,4 @@ public interface ISqlObjectManagerAppService : IApplicationService /// Non-existing files are ignored. /// Task DeleteSqlDataFilesAsync(DeleteSqlDataFilesDto input); - - /// - /// Lists .sql files currently available under DbMigrator Seeds/SqlData. - /// - Task> GetSqlDataFilesAsync(string dataDirectoryName = "SqlData", string relativePath = ""); - - /// - /// Reads a .sql seed file content from the selected data directory. - /// - Task GetSqlDataFileContentAsync(string dataDirectoryName = "SqlData", string relativePath = ""); - - /// - /// Moves a SQL seed file between the selected data directory root and HostData. - /// - Task MoveSqlDataFileAsync(MoveSqlDataFileDto input); } diff --git a/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/SqlDataFileDto.cs b/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/SqlDataFileDto.cs deleted file mode 100644 index 3e64ead5..00000000 --- a/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/SqlDataFileDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace Sozsoft.SqlQueryManager.Application.Contracts; - -public class SqlDataFileDto -{ - public string FileName { get; set; } = string.Empty; - public string Name { get; set; } = string.Empty; - public string RelativePath { get; set; } = string.Empty; - public bool IsDirectory { get; set; } - public DateTime CreatedAt { get; set; } -} diff --git a/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/SqlExecutionDto.cs b/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/SqlExecutionDto.cs index f7c22d15..83f44171 100644 --- a/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/SqlExecutionDto.cs +++ b/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application.Contracts/SqlExecutionDto.cs @@ -46,10 +46,3 @@ public class DeleteSqlDataFilesDto /// public List FileNames { get; set; } = new(); } - -public class MoveSqlDataFileDto -{ - public string DataDirectoryName { get; set; } = string.Empty; - public string SourceRelativePath { get; set; } = string.Empty; - public string TargetRelativePath { get; set; } = string.Empty; -} diff --git a/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application/SqlObjectManagerAppService.cs b/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application/SqlObjectManagerAppService.cs index e4c46cb4..b02b6621 100644 --- a/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application/SqlObjectManagerAppService.cs +++ b/api/modules/Sozsoft.SqlQueryManager/Sozsoft.SqlQueryManager.Application/SqlObjectManagerAppService.cs @@ -954,107 +954,6 @@ FROM ( return Task.CompletedTask; } - [HttpGet("api/app/sql-object-manager/sql-data-files")] - public Task> GetSqlDataFilesAsync( - [FromQuery] string dataDirectoryName = "SqlData", - [FromQuery] string relativePath = "") - { - ValidateTenantAccess(); - - try - { - var rootPath = ResolveSqlDataOutputPath(dataDirectoryName); - var outputPath = ResolveSqlDataChildPath(rootPath, relativePath); - if (!Directory.Exists(outputPath)) - return Task.FromResult(new List()); - - var directories = Directory.GetDirectories(outputPath, "*", SearchOption.TopDirectoryOnly) - .Where(d => string.Equals(Path.GetFileName(d), "HostData", StringComparison.OrdinalIgnoreCase)) - .Select(d => new SqlDataFileDto - { - FileName = Path.GetFileName(d)!, - Name = Path.GetFileName(d)!, - RelativePath = BuildSqlDataRelativePath(relativePath, Path.GetFileName(d)!), - IsDirectory = true, - CreatedAt = Directory.GetCreationTime(d) - }); - - var files = Directory.GetFiles(outputPath, "*.sql", SearchOption.TopDirectoryOnly) - .Select(f => new SqlDataFileDto - { - FileName = Path.GetFileName(f)!, - Name = Path.GetFileName(f)!, - RelativePath = BuildSqlDataRelativePath(relativePath, Path.GetFileName(f)!), - IsDirectory = false, - CreatedAt = File.GetCreationTime(f) - }) - .Where(x => !string.IsNullOrWhiteSpace(x.Name)); - - var entries = directories - .Concat(files) - .OrderByDescending(x => x.IsDirectory) - .ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - - return Task.FromResult(entries); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to list SQL seed files: {Message}", ex.Message); - return Task.FromResult(new List()); - } - } - - [HttpGet("api/app/sql-object-manager/sql-data-file-content")] - public async Task GetSqlDataFileContentAsync( - [FromQuery] string dataDirectoryName = "SqlData", - [FromQuery] string relativePath = "") - { - ValidateTenantAccess(); - - var rootPath = ResolveSqlDataOutputPath(dataDirectoryName); - var filePath = ResolveSqlDataChildPath(rootPath, relativePath); - - if (!File.Exists(filePath)) - throw new Volo.Abp.UserFriendlyException("SQL seed file was not found."); - - if (!string.Equals(Path.GetExtension(filePath), ".sql", StringComparison.OrdinalIgnoreCase)) - throw new Volo.Abp.UserFriendlyException("Only .sql files can be previewed."); - - return await File.ReadAllTextAsync(filePath); - } - - [HttpPost("api/app/sql-object-manager/move-sql-data-file")] - public Task MoveSqlDataFileAsync(MoveSqlDataFileDto input) - { - ValidateTenantAccess(); - - if (input == null) - throw new Volo.Abp.UserFriendlyException("Invalid move request."); - - var rootPath = ResolveSqlDataOutputPath(input.DataDirectoryName); - var sourcePath = ResolveSqlDataChildPath(rootPath, input.SourceRelativePath); - var targetPath = ResolveSqlDataChildPath(rootPath, input.TargetRelativePath); - - if (!File.Exists(sourcePath)) - throw new Volo.Abp.UserFriendlyException("Source file was not found."); - - if (!string.Equals(Path.GetExtension(sourcePath), ".sql", StringComparison.OrdinalIgnoreCase) || - !string.Equals(Path.GetExtension(targetPath), ".sql", StringComparison.OrdinalIgnoreCase)) - { - throw new Volo.Abp.UserFriendlyException("Only .sql files can be moved."); - } - - Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); - - if (File.Exists(targetPath)) - throw new Volo.Abp.UserFriendlyException("A file with the same name already exists in the target folder."); - - File.Move(sourcePath, targetPath); - _logger.LogInformation("SQL seed file moved from {SourcePath} to {TargetPath}", sourcePath, targetPath); - - return Task.CompletedTask; - } private string ResolveSqlDataOutputPath() { @@ -1090,55 +989,4 @@ FROM ( : "SqlData"; } - private static string ResolveSqlDataChildPath(string rootPath, string relativePath) - { - var normalized = NormalizeSqlDataRelativePath(relativePath); - var fullPath = Path.GetFullPath(Path.Combine(rootPath, normalized)); - var fullRoot = Path.GetFullPath(rootPath); - var fullRootWithSeparator = fullRoot.EndsWith(Path.DirectorySeparatorChar) - ? fullRoot - : fullRoot + Path.DirectorySeparatorChar; - - if (!string.Equals(fullPath, fullRoot, StringComparison.OrdinalIgnoreCase) && - !fullPath.StartsWith(fullRootWithSeparator, StringComparison.OrdinalIgnoreCase)) - { - throw new Volo.Abp.UserFriendlyException("Invalid path."); - } - - return fullPath; - } - - private static string NormalizeSqlDataRelativePath(string relativePath) - { - if (string.IsNullOrWhiteSpace(relativePath)) - return string.Empty; - - var normalized = relativePath.Replace('\\', '/').Trim('/'); - var parts = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); - - if (parts.Length == 0) - return string.Empty; - - if (parts.Any(p => p == "." || p == ".." || p.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)) - throw new Volo.Abp.UserFriendlyException("Invalid path."); - - var isRootFile = parts.Length == 1 && parts[0].EndsWith(".sql", StringComparison.OrdinalIgnoreCase); - var isHostDataFolder = parts.Length == 1 && string.Equals(parts[0], "HostData", StringComparison.OrdinalIgnoreCase); - var isHostDataFile = parts.Length == 2 && - string.Equals(parts[0], "HostData", StringComparison.OrdinalIgnoreCase) && - parts[1].EndsWith(".sql", StringComparison.OrdinalIgnoreCase); - - if (!isRootFile && !isHostDataFolder && !isHostDataFile) - throw new Volo.Abp.UserFriendlyException("Invalid path."); - - return Path.Combine(parts); - } - - private static string BuildSqlDataRelativePath(string parentRelativePath, string name) - { - if (string.IsNullOrWhiteSpace(parentRelativePath)) - return name; - - return $"{parentRelativePath.Trim('/', '\\')}/{name}"; - } } diff --git a/api/src/Sozsoft.Platform.Application/FileManagement/FileManagementAppService.cs b/api/src/Sozsoft.Platform.Application/FileManagement/FileManagementAppService.cs index 997c7f42..cf9690da 100644 --- a/api/src/Sozsoft.Platform.Application/FileManagement/FileManagementAppService.cs +++ b/api/src/Sozsoft.Platform.Application/FileManagement/FileManagementAppService.cs @@ -8,8 +8,10 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; using Sozsoft.Platform.BlobStoring; +using Sozsoft.Platform.Permissions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Volo.Abp; using Volo.Abp.Application.Services; @@ -24,10 +26,20 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe private readonly ICurrentTenant _currentTenant; private readonly BlobManager _blobContainer; private readonly IConfiguration _configuration; + private readonly IHostEnvironment _hostEnvironment; private const string FolderMarkerSuffix = ".folder"; private const string IndexFileName = "index.json"; + /// + /// CDN altinda fiziksel karsiligi olmayan, DbMigrator projesinin Seeds klasorune baglanan + /// sanal kok klasor. Sadece host baglaminda ve + /// yetkisi olan kullaniciya gorunur. + /// + private const string SeedsFolderName = "Seeds"; + + private const string DbMigratorProjectName = "Sozsoft.Platform.DbMigrator"; + private static readonly HashSet ProtectedFolders = new(StringComparer.OrdinalIgnoreCase) { BlobContainerNames.Intranet, @@ -35,18 +47,21 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe BlobContainerNames.Import, BlobContainerNames.Messenger, BlobContainerNames.Note, - BlobContainerNames.Backup + BlobContainerNames.Backup, + SeedsFolderName }; public FileManagementAppService( ICurrentTenant currentTenant, BlobManager blobContainer, - IConfiguration configuration + IConfiguration configuration, + IHostEnvironment hostEnvironment ) { _currentTenant = currentTenant; _blobContainer = blobContainer; _configuration = configuration; + _hostEnvironment = hostEnvironment; } private const string HostFolderName = "host"; @@ -67,6 +82,119 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe : Path.Combine(cdnBasePath, TenantsFolderName, effectiveTenantId); } + private static bool IsSeedsPath(string? relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + return false; + } + + var firstPart = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + return string.Equals(firstPart, SeedsFolderName, StringComparison.OrdinalIgnoreCase); + } + + /// Host + migration yetkisi olmadan DbMigrator Seeds klasoru ne listelenir ne de degistirilir. + private async Task CanAccessSeedsAsync(string tenantId) + { + return IsHostTenant(tenantId) && + await AuthorizationService.IsGrantedAsync(PlatformPermissions.Setup.Migrate); + } + + /// DbMigrator projesinin Seeds klasorunu bulur (config ile override edilebilir). + private string ResolveSeedsRootPath() + { + var configuredPath = _configuration["App:DbMigratorSeedsPath"]; + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + return Path.GetFullPath(configuredPath); + } + + var dir = new DirectoryInfo(_hostEnvironment.ContentRootPath); + + while (dir != null) + { + var candidate = Path.Combine(dir.FullName, "src", DbMigratorProjectName, "Seeds"); + if (Directory.Exists(candidate)) + { + return candidate; + } + + candidate = Path.Combine(dir.FullName, DbMigratorProjectName, "Seeds"); + if (Directory.Exists(candidate)) + { + return candidate; + } + + dir = dir.Parent; + } + + return Path.Combine(_hostEnvironment.ContentRootPath, "Seeds"); + } + + /// + /// Mantiksal yolu (CDN ya da sanal Seeds koku) fiziksel yola cevirir ve kok disina cikilmasini engeller. + /// + private async Task ResolveFullPathAsync(string cdnBasePath, string tenantId, string? relativePath) + { + string rootPath; + var pathInsideRoot = relativePath ?? string.Empty; + + if (IsSeedsPath(relativePath)) + { + if (!await CanAccessSeedsAsync(tenantId)) + { + throw new UserFriendlyException($"'{SeedsFolderName}' folder is not accessible."); + } + + rootPath = ResolveSeedsRootPath(); + pathInsideRoot = string.Join('/', relativePath! + .Replace('\\', '/') + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .Skip(1)); + } + else + { + rootPath = GetCdnTenantRootPath(cdnBasePath, tenantId); + } + + var fullRoot = Path.GetFullPath(rootPath); + + if (string.IsNullOrEmpty(pathInsideRoot)) + { + return fullRoot; + } + + var fullPath = Path.GetFullPath(Path.Combine(fullRoot, ToSystemPath(pathInsideRoot))); + var fullRootWithSeparator = fullRoot.EndsWith(Path.DirectorySeparatorChar) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + + if (!string.Equals(fullPath, fullRoot, StringComparison.OrdinalIgnoreCase) && + !fullPath.StartsWith(fullRootWithSeparator, StringComparison.OrdinalIgnoreCase)) + { + throw new UserFriendlyException("Invalid path"); + } + + return fullPath; + } + + private static FileMetadata CreateDirectoryMetadata(DirectoryInfo dirInfo, string relativePath, string? parentPath, string tenantId, int childCount) + { + return new FileMetadata + { + Id = relativePath.Replace("/", "|"), + Name = dirInfo.Name, + Type = "folder", + CreatedAt = dirInfo.CreationTime, + ModifiedAt = dirInfo.LastWriteTime, + Path = relativePath, + ParentId = parentPath ?? "", + IsReadOnly = false, + TenantId = tenantId == "host" ? null : tenantId, + ChildCount = childCount + }; + } + private static string NormalizeExtension(string? extensionOrFileName) { if (string.IsNullOrWhiteSpace(extensionOrFileName)) @@ -169,23 +297,34 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe return items; } - var fullPath = GetCdnTenantRootPath(cdnBasePath, tenantId); - - if (!string.IsNullOrEmpty(folderPath)) - { - fullPath = Path.Combine(fullPath, ToSystemPath(folderPath)); - } + var isRootLevel = string.IsNullOrEmpty(folderPath); + var showSeedsFolder = isRootLevel && await CanAccessSeedsAsync(tenantId); + var fullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, folderPath); try { if (!Directory.Exists(fullPath)) { Logger.LogWarning($"Directory does not exist: {fullPath}"); + + if (showSeedsFolder) + { + AddSeedsFolderItem(items, tenantId); + } + return items; } - // Klasörleri listele - var directories = Directory.GetDirectories(fullPath); + if (showSeedsFolder) + { + AddSeedsFolderItem(items, tenantId); + } + + // Klasörleri listele (sanal Seeds klasoru ile ayni adli fiziksel klasor gizlenir) + var directories = Directory.GetDirectories(fullPath) + .Where(d => !showSeedsFolder || + !string.Equals(new DirectoryInfo(d).Name, SeedsFolderName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); foreach (var dir in directories) { var dirInfo = new DirectoryInfo(dir); @@ -255,6 +394,24 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe } } + /// Kok listeye DbMigrator Seeds klasorunu sanal klasor olarak ekler. + private void AddSeedsFolderItem(List items, string tenantId) + { + var seedsRootPath = ResolveSeedsRootPath(); + if (!Directory.Exists(seedsRootPath)) + { + Logger.LogWarning("DbMigrator seeds directory was not found: {SeedsPath}", seedsRootPath); + return; + } + + var dirInfo = new DirectoryInfo(seedsRootPath); + var childCount = Directory.GetFileSystemEntries(seedsRootPath, "*", SearchOption.TopDirectoryOnly).Length; + + var metadata = CreateDirectoryMetadata(dirInfo, SeedsFolderName, string.Empty, tenantId, childCount); + metadata.Name = SeedsFolderName; + items.Add(metadata); + } + private async Task SaveFolderIndexAsync(List items, string tenantId, string? parentId = null) { var indexPath = GetTenantPrefix(tenantId) + (string.IsNullOrEmpty(parentId) ? IndexFileName : $"{parentId}/{IndexFileName}"); @@ -307,15 +464,14 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe } var tenantId = GetEffectiveTenantId(input.TenantId); - var parentPath = GetCdnTenantRootPath(cdnBasePath, tenantId); string? decodedParentId = null; if (!string.IsNullOrEmpty(input.ParentId)) { decodedParentId = DecodeIdAsPath(input.ParentId); - parentPath = Path.Combine(parentPath, ToSystemPath(decodedParentId)); } + var parentPath = await ResolveFullPathAsync(cdnBasePath, tenantId, decodedParentId); var folderPath = Path.Combine(parentPath, input.Name); // Klasör zaten var mı kontrol et @@ -383,12 +539,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe throw new UserFriendlyException("CDN path is not configured"); } - var fullCdnPath = GetCdnTenantRootPath(cdnBasePath, tenantId); - - if (!string.IsNullOrEmpty(decodedParentId)) - { - fullCdnPath = Path.Combine(fullCdnPath, ToSystemPath(decodedParentId)); - } + var fullCdnPath = await ResolveFullPathAsync(cdnBasePath, tenantId, decodedParentId); // Dizini oluştur Directory.CreateDirectory(fullCdnPath); @@ -556,7 +707,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe var effectiveTenantId = GetEffectiveTenantId(tenantId); var actualPath = DecodeIdAsPath(id); - var fullPath = Path.Combine(GetCdnTenantRootPath(cdnBasePath, effectiveTenantId), ToSystemPath(actualPath)); + var fullPath = await ResolveFullPathAsync(cdnBasePath, effectiveTenantId, actualPath); if (Directory.Exists(fullPath)) { @@ -599,7 +750,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe ValidateNotProtectedFolder(itemId, "delete"); var actualPath = DecodeIdAsPath(itemId); - var fullPath = Path.Combine(GetCdnTenantRootPath(cdnBasePath, tenantId), ToSystemPath(actualPath)); + var fullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, actualPath); if (Directory.Exists(fullPath)) { @@ -643,7 +794,6 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe } var tenantId = GetEffectiveTenantId(input.TenantId); - var basePath = GetCdnTenantRootPath(cdnBasePath, tenantId); string? targetPath = null; if (!string.IsNullOrEmpty(input.TargetFolderId)) @@ -659,17 +809,17 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe try { var sourcePath = DecodeIdAsPath(itemId); - var sourceFullPath = Path.Combine(basePath, ToSystemPath(sourcePath)); + var sourceFullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, sourcePath); // Get source item name var sourceItemName = Path.GetFileName(sourcePath); // Generate unique name if item already exists in target var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}"; - var targetFullPath = Path.Combine(basePath, ToSystemPath(targetItemPath)); + var targetFullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, targetItemPath); var uniqueTargetPath = GetUniqueItemPath(targetFullPath, sourceItemName); - var finalTargetPath = uniqueTargetPath.Replace(basePath + Path.DirectorySeparatorChar, "").Replace(Path.DirectorySeparatorChar, '/'); + var finalTargetPath = BuildLogicalPath(targetPath, Path.GetFileName(uniqueTargetPath)); if (Directory.Exists(sourceFullPath)) { @@ -756,7 +906,6 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe } var tenantId = GetEffectiveTenantId(input.TenantId); - var basePath = GetCdnTenantRootPath(cdnBasePath, tenantId); string? targetPath = null; if (!string.IsNullOrEmpty(input.TargetFolderId)) @@ -775,14 +924,14 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe ValidateNotProtectedFolder(itemId, "move"); var sourcePath = DecodeIdAsPath(itemId); - var sourceFullPath = Path.Combine(basePath, ToSystemPath(sourcePath)); + var sourceFullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, sourcePath); // Get source item name var sourceItemName = Path.GetFileName(sourcePath); // Generate target path var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}"; - var targetFullPath = Path.Combine(basePath, ToSystemPath(targetItemPath)); + var targetFullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, targetItemPath); // Check if moving to same location if (Path.GetFullPath(sourceFullPath) == Path.GetFullPath(targetFullPath)) @@ -793,7 +942,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe // Generate unique name if item already exists in target var uniqueTargetPath = GetUniqueItemPath(targetFullPath, sourceItemName); - var finalTargetPath = uniqueTargetPath.Replace(basePath + Path.DirectorySeparatorChar, "").Replace(Path.DirectorySeparatorChar, '/'); + var finalTargetPath = BuildLogicalPath(targetPath, Path.GetFileName(uniqueTargetPath)); if (Directory.Exists(sourceFullPath)) { @@ -882,7 +1031,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe var effectiveTenantId = GetEffectiveTenantId(tenantId); var actualPath = DecodeIdAsPath(id); - var fullFilePath = Path.Combine(GetCdnTenantRootPath(cdnBasePath, effectiveTenantId), ToSystemPath(actualPath)); + var fullFilePath = await ResolveFullPathAsync(cdnBasePath, effectiveTenantId, actualPath); if (!File.Exists(fullFilePath)) { @@ -1077,6 +1226,11 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe return new FolderPathDto { Path = pathItems }; } + private static string BuildLogicalPath(string? parentPath, string name) + { + return string.IsNullOrEmpty(parentPath) ? name : $"{parentPath}/{name}"; + } + private string GetUniqueItemPath(string targetPath, string originalName) { if (!File.Exists(targetPath) && !Directory.Exists(targetPath)) diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/AfterAllMigrationsSqlExecutor.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/AfterAllMigrationsSqlExecutor.cs new file mode 100644 index 00000000..c946375b --- /dev/null +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/AfterAllMigrationsSqlExecutor.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Sozsoft.Platform.EntityFrameworkCore; +using Volo.Abp.DependencyInjection; +using Volo.Abp.EntityFrameworkCore; +using Volo.Abp.Uow; +using static Sozsoft.Settings.SettingsConsts; + +namespace Sozsoft.Platform.Data.Seeds; + +/// +/// Seeds/{SqlData|PostgresData}/{HostData|TenantData}/ExecuteData klasöründeki .sql dosyaları +/// iki aşamada işlenir: +/// 1) Migration/seed sırasında tarafından, üst klasörle (HostData/TenantData) +/// aynı kapsamda çalıştırılır (nesne oluşturulur/güncellenir). +/// 2) Tüm migration ve seed işlemleri bittikten sonra bu sınıf, dosya adından türetilen stored +/// procedure'ü ilgili bağlamda (host ya da tenant) varlığı doğrulanarak çalıştırır. +/// Dosya adı = procedure adı. İsteğe bağlı olarak "schema.Procedure.sql" formatı da desteklenir. +/// +public class AfterAllMigrationsSqlExecutor : IAfterAllMigrationsSqlExecutor, ITransientDependency +{ + /// HostData/TenantData altındaki, migration sonrası da çalıştırılacak script klasörünün adı. + public const string DirectoryName = "ExecuteData"; + + /// Host kapsamındaki script klasörü. + public const string HostDataDirectoryName = "HostData"; + + /// Tenant kapsamındaki script klasörü (henüz kullanılmıyorsa sessizce atlanır). + public const string TenantDataDirectoryName = "TenantData"; + + private static readonly Regex SafeIdentifierRegex = + new(@"^[A-Za-z_][A-Za-z0-9_]{0,126}$", RegexOptions.Compiled); + + private readonly IDbContextProvider _dbContextProvider; + private readonly IUnitOfWorkManager _unitOfWorkManager; + private readonly ILogger _logger; + + public AfterAllMigrationsSqlExecutor( + IDbContextProvider dbContextProvider, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) + { + _dbContextProvider = dbContextProvider; + _unitOfWorkManager = unitOfWorkManager; + _logger = logger; + } + + public async Task ExecuteAsync(Guid? tenantId = null) + { + var scopeDirectoryName = tenantId == null ? HostDataDirectoryName : TenantDataDirectoryName; + var directoryPath = GetScriptDirectoryPath(scopeDirectoryName); + + if (!Directory.Exists(directoryPath)) + { + _logger.LogInformation("{Directory} directory not found, skipping AfterAllMigrationsSqlExecutor.", directoryPath); + return; + } + + var sqlFiles = Directory.GetFiles(directoryPath, "*.sql") + .OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (sqlFiles.Length == 0) + { + _logger.LogInformation("No .sql files found in {Directory}, skipping AfterAllMigrationsSqlExecutor.", directoryPath); + return; + } + + _logger.LogInformation( + "AfterAllMigrationsSqlExecutor started for {Scope}. {Count} procedure(s) to be executed.", + tenantId == null ? "Host" : $"Tenant {tenantId}", + sqlFiles.Length); + + var failed = new List(); + + foreach (var filePath in sqlFiles) + { + var objectName = Path.GetFileNameWithoutExtension(filePath); + + if (!TryResolveObjectName(objectName, out var schemaName, out var procedureName)) + { + _logger.LogWarning( + "Skipped {FileName}: file name is not a valid procedure identifier ([schema.]ProcedureName).", + Path.GetFileName(filePath)); + continue; + } + + try + { + using var uow = _unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + + var dbContext = await _dbContextProvider.GetDbContextAsync(); + + _logger.LogInformation( + "Executing after-all-migrations procedure {Schema}.{Procedure} from {FileName}", + schemaName, + procedureName, + Path.GetFileName(filePath)); + + await dbContext.Database.ExecuteSqlRawAsync(BuildExecuteScript(schemaName, procedureName)); + + await uow.CompleteAsync(); + + _logger.LogInformation( + "Executed after-all-migrations procedure {Schema}.{Procedure}", + schemaName, + procedureName); + } + catch (Exception ex) + { + failed.Add($"{schemaName}.{procedureName}"); + _logger.LogError(ex, "After-all-migrations procedure failed: {Schema}.{Procedure}", schemaName, procedureName); + } + } + + if (failed.Count > 0) + { + _logger.LogWarning( + "AfterAllMigrationsSqlExecutor completed with {FailedCount} failed procedure(s): {Failed}", + failed.Count, + string.Join(", ", failed)); + } + else + { + _logger.LogInformation("AfterAllMigrationsSqlExecutor completed. {Count} procedure(s) executed.", sqlFiles.Length); + } + } + + private static string GetScriptDirectoryPath(string scopeDirectoryName) + { + var dataDirectoryName = DefaultDatabaseProvider == DatabaseProvider.PostgreSql + ? "PostgresData" + : "SqlData"; + + return Path.Combine(Directory.GetCurrentDirectory(), "Seeds", dataDirectoryName, scopeDirectoryName, DirectoryName); + } + + /// + /// Dosya adını [schema.]ProcedureName olarak çözer ve her iki parçayı da beyaz liste ile doğrular. + /// Doğrulanmayan hiçbir ad SQL metnine yazılmaz. + /// + private static bool TryResolveObjectName(string fileName, out string schemaName, out string procedureName) + { + var defaultSchema = DefaultDatabaseProvider == DatabaseProvider.PostgreSql ? "public" : "dbo"; + + schemaName = defaultSchema; + procedureName = fileName; + + var parts = fileName.Split('.'); + if (parts.Length == 2) + { + schemaName = parts[0]; + procedureName = parts[1]; + } + else if (parts.Length > 2) + { + return false; + } + + return SafeIdentifierRegex.IsMatch(schemaName) && SafeIdentifierRegex.IsMatch(procedureName); + } + + private static string BuildExecuteScript(string schemaName, string procedureName) + { + if (DefaultDatabaseProvider == DatabaseProvider.PostgreSql) + { + return $@" +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = '{schemaName}' AND p.proname = '{procedureName}' AND p.prokind = 'p' + ) THEN + CALL ""{schemaName}"".""{procedureName}""(); + ELSE + RAISE NOTICE 'Procedure {schemaName}.{procedureName} not found, skipped.'; + END IF; +END +$$;"; + } + + return $@" +IF OBJECT_ID(N'[{schemaName}].[{procedureName}]', N'P') IS NOT NULL + EXEC [{schemaName}].[{procedureName}]; +ELSE + PRINT N'Procedure [{schemaName}].[{procedureName}] not found, skipped.';"; + } +} diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/HangfireDbSchemaMigrator.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/HangfireDbSchemaMigrator.cs similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/HangfireDbSchemaMigrator.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/HangfireDbSchemaMigrator.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/HostDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/HostDataSeeder.cs similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/HostDataSeeder.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/HostDataSeeder.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguageDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguageDataSeeder.cs similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguageDataSeeder.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguageDataSeeder.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Administration.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Administration.cs similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Administration.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Administration.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_DefaultJsons.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_DefaultJsons.cs similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_DefaultJsons.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_DefaultJsons.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Saas.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Utils.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Utils.cs similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_Utils.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Utils.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenuDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/MenuDataSeeder.cs similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/MenuDataSeeder.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/MenuDataSeeder.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/PermissionDataSeeder.cs similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionDataSeeder.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/PermissionDataSeeder.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PlatformIdentityDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/PlatformIdentityDataSeeder.cs similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/PlatformIdentityDataSeeder.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/PlatformIdentityDataSeeder.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/SqlDataSeeder.cs similarity index 87% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlDataSeeder.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/SqlDataSeeder.cs index 2fbf4e4a..9012237c 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlDataSeeder.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/SqlDataSeeder.cs @@ -111,18 +111,28 @@ public class SqlDataSeeder : IDataSeedContributor, ITransientDependency : "SqlData"; } - private static string[] GetSqlFiles(string dataDirectoryPath, bool includeHostData) + private static string[] GetSqlFiles(string dataDirectoryPath, bool isHostSeed) { var sqlFiles = Directory.GetFiles(dataDirectoryPath, "*.sql") .OrderBy(f => Path.GetFileName(f)) .ToList(); - if (includeHostData) + var scopeDirectoryName = isHostSeed + ? AfterAllMigrationsSqlExecutor.HostDataDirectoryName + : AfterAllMigrationsSqlExecutor.TenantDataDirectoryName; + + var scopePath = Path.Combine(dataDirectoryPath, scopeDirectoryName); + if (Directory.Exists(scopePath)) { - var hostDataPath = Path.Combine(dataDirectoryPath, "HostData"); - if (Directory.Exists(hostDataPath)) + sqlFiles.AddRange(Directory.GetFiles(scopePath, "*.sql") + .OrderBy(f => Path.GetFileName(f))); + + // {HostData|TenantData}/ExecuteData: nesneler burada da oluşturulur, ayrıca tüm + // migration bittikten sonra AfterAllMigrationsSqlExecutor dosya adı üzerinden çalıştırır. + var executeDataPath = Path.Combine(scopePath, AfterAllMigrationsSqlExecutor.DirectoryName); + if (Directory.Exists(executeDataPath)) { - sqlFiles.AddRange(Directory.GetFiles(hostDataPath, "*.sql") + sqlFiles.AddRange(Directory.GetFiles(executeDataPath, "*.sql") .OrderBy(f => Path.GetFileName(f))); } } diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/WizardDataSeeder.cs similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardDataSeeder.cs rename to api/src/Sozsoft.Platform.DbMigrator/Migrations/WizardDataSeeder.cs diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index 36808030..d0545d02 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -18440,21 +18440,15 @@ }, { "resourceName": "Platform", - "key": "App.SqlQueryManager.MoveFiles", - "en": "Move to Host Folder", - "tr": "Host Klasörüne Taşı" + "key": "App.SqlQueryManager.OpenFileManager", + "en": "Seed Files", + "tr": "Seed Dosyaları" }, { "resourceName": "Platform", - "key": "App.SqlQueryManager.MoveToHostData", - "en": "Move to Host Data", - "tr": "Host Klasörüne Taşı" - }, - { - "resourceName": "Platform", - "key": "App.SqlQueryManager.MoveOut", - "en": "Move Out", - "tr": "Dışarı Taşı" + "key": "App.SqlQueryManager.OpenFileManagerHint", + "en": "Manage DbMigrator Seeds folder in File Manager", + "tr": "DbMigrator Seeds klasörünü Dosya Yöneticisi'nde yönet" }, { "resourceName": "Platform", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PostgresData/HostData/Sas_H_ListFormFieldsDictionaryJson_SaveDbTableColumns.sql b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PostgresData/HostData/ExecuteData/Sas_H_ListFormFieldsDictionaryJson_SaveDbTableColumns.sql similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/PostgresData/HostData/Sas_H_ListFormFieldsDictionaryJson_SaveDbTableColumns.sql rename to api/src/Sozsoft.Platform.DbMigrator/Seeds/PostgresData/HostData/ExecuteData/Sas_H_ListFormFieldsDictionaryJson_SaveDbTableColumns.sql diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/HostData/Sas_H_ListFormFieldsDictionaryJson_SaveDbTableColumns.sql b/api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/HostData/ExecuteData/Sas_H_ListFormFieldsDictionaryJson_SaveDbTableColumns.sql similarity index 100% rename from api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/HostData/Sas_H_ListFormFieldsDictionaryJson_SaveDbTableColumns.sql rename to api/src/Sozsoft.Platform.DbMigrator/Seeds/SqlData/HostData/ExecuteData/Sas_H_ListFormFieldsDictionaryJson_SaveDbTableColumns.sql diff --git a/api/src/Sozsoft.Platform.DbMigrator/Sozsoft.Platform.DbMigrator.csproj b/api/src/Sozsoft.Platform.DbMigrator/Sozsoft.Platform.DbMigrator.csproj index ede20f88..63576d35 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Sozsoft.Platform.DbMigrator.csproj +++ b/api/src/Sozsoft.Platform.DbMigrator/Sozsoft.Platform.DbMigrator.csproj @@ -89,6 +89,18 @@ PreserveNewest Always + + PreserveNewest + Always + + + PreserveNewest + Always + + + PreserveNewest + Always + PreserveNewest Always @@ -97,6 +109,18 @@ PreserveNewest Always + + PreserveNewest + Always + + + PreserveNewest + Always + + + PreserveNewest + Always + diff --git a/api/src/Sozsoft.Platform.Domain/Data/IAfterAllMigrationsSqlExecutor.cs b/api/src/Sozsoft.Platform.Domain/Data/IAfterAllMigrationsSqlExecutor.cs new file mode 100644 index 00000000..15f89d0f --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/Data/IAfterAllMigrationsSqlExecutor.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading.Tasks; + +namespace Sozsoft.Platform.Data; + +/// +/// Tüm migration ve seed işlemleri tamamlandıktan sonra çalıştırılacak SQL adımlarını yürütür. +/// Uygulaması DbMigrator projesindedir; migrator dışındaki host'larda kayıtlı olmayabilir. +/// +public interface IAfterAllMigrationsSqlExecutor +{ + /// + /// null ise HostData/ExecuteData, aksi halde TenantData/ExecuteData klasörü işlenir. + /// + Task ExecuteAsync(Guid? tenantId = null); +} diff --git a/api/src/Sozsoft.Platform.Domain/Data/PlatformDbMigrationService.cs b/api/src/Sozsoft.Platform.Domain/Data/PlatformDbMigrationService.cs index b03ad1c6..27e174eb 100644 --- a/api/src/Sozsoft.Platform.Domain/Data/PlatformDbMigrationService.cs +++ b/api/src/Sozsoft.Platform.Domain/Data/PlatformDbMigrationService.cs @@ -26,19 +26,22 @@ public class PlatformDbMigrationService : ITransientDependency private readonly ITenantRepository _tenantRepository; private readonly ICurrentTenant _currentTenant; private readonly IConfiguration _configuration; + private readonly IEnumerable _afterAllMigrationsSqlExecutors; public PlatformDbMigrationService( IDataSeeder dataSeeder, IEnumerable dbSchemaMigrators, ITenantRepository tenantRepository, ICurrentTenant currentTenant, - IConfiguration configuration) + IConfiguration configuration, + IEnumerable afterAllMigrationsSqlExecutors) { _dataSeeder = dataSeeder; _dbSchemaMigrators = dbSchemaMigrators; _tenantRepository = tenantRepository; _currentTenant = currentTenant; _configuration = configuration; + _afterAllMigrationsSqlExecutors = afterAllMigrationsSqlExecutors; Logger = NullLogger.Instance; } @@ -100,9 +103,59 @@ public class PlatformDbMigrationService : ITransientDependency } Logger.LogInformation("Successfully completed all database migrations."); + + await ExecuteAfterAllMigrationsSqlAsync(tenants); + Logger.LogInformation("You can safely end this process..."); } + /// + /// Tüm host ve tenant migration/seed adımları bittikten sonra, {HostData|TenantData}/ExecuteData + /// klasöründeki script'lerin dosya adından türeyen procedure'lerini ilgili bağlamda çalıştırır. + /// TenantData klasörü yoksa tenant adımı sessizce atlanır. + /// + private async Task ExecuteAfterAllMigrationsSqlAsync(List tenants) + { + if (!_afterAllMigrationsSqlExecutors.Any()) + { + Logger.LogInformation("No after-all-migrations SQL executor registered, skipping ExecuteData scripts."); + return; + } + + Logger.LogInformation( + "Executing after-all-migrations SQL scripts for host and {TenantCount} tenant(s)...", + tenants.Count); + + using (_currentTenant.Change(null)) + { + foreach (var executor in _afterAllMigrationsSqlExecutors) + { + Logger.LogInformation("Running after-all-migrations SQL scripts for host..."); + await executor.ExecuteAsync(); + Logger.LogInformation("Completed after-all-migrations SQL scripts for host."); + } + } + + foreach (var tenant in tenants) + { + using (_currentTenant.Change(tenant.Id)) + { + foreach (var executor in _afterAllMigrationsSqlExecutors) + { + Logger.LogInformation( + "Running after-all-migrations SQL scripts for {TenantName} tenant...", + tenant.Name); + await executor.ExecuteAsync(tenant.Id); + Logger.LogInformation( + "Completed after-all-migrations SQL scripts for {TenantName} tenant.", + tenant.Name); + } + } + } + + Logger.LogInformation("Successfully completed all after-all-migrations SQL scripts."); + } + private async Task MigrateDatabaseSchemaAsync(Tenant? tenant = null) { Logger.LogInformation( diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/appsettings.Dev.json b/api/src/Sozsoft.Platform.HttpApi.Host/appsettings.Dev.json index 3db9106b..c6499720 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/appsettings.Dev.json +++ b/api/src/Sozsoft.Platform.HttpApi.Host/appsettings.Dev.json @@ -7,6 +7,7 @@ "AttachmentsPath": "/etc/api/mail-queue/attachments", "CdnUrl": "https://dev-cdn.sozsoft.com", "CdnPath": "/etc/api/cdn", + "DbMigratorSeedsPath": "/etc/api/seeds", "BaseDomain": "sozsoft.com", "BackupPath": "/var/opt/mssql/backup" }, diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/appsettings.Production.json b/api/src/Sozsoft.Platform.HttpApi.Host/appsettings.Production.json index 30d7543a..70e54a1d 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/appsettings.Production.json +++ b/api/src/Sozsoft.Platform.HttpApi.Host/appsettings.Production.json @@ -7,6 +7,7 @@ "AttachmentsPath": "/etc/api/mail-queue/attachments", "CdnUrl": "https://cdn.sozsoft.com", "CdnPath": "/etc/api/cdn", + "DbMigratorSeedsPath": "/etc/api/seeds", "BaseDomain": "sozsoft.com", "BackupPath": "/var/opt/mssql/backup" }, diff --git a/configs/deployment/docker-compose-app.yml b/configs/deployment/docker-compose-app.yml index 593884d8..5305d418 100644 --- a/configs/deployment/docker-compose-app.yml +++ b/configs/deployment/docker-compose-app.yml @@ -8,6 +8,9 @@ networks: volumes: cdn: api-keys: + # DbMigrator imajindaki Seeds klasoru ilk calistirmada bu volume'e kopyalanir; + # api konteyneri ayni volume'u /etc/api/seeds altinda okuyup yazar. + seeds: sql-backups: external: true name: sozsoft-platform-data_sql-backups @@ -21,6 +24,8 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Dev - SEED=${SEED} + volumes: + - seeds:/srv/app/Seeds networks: - db @@ -35,6 +40,7 @@ services: volumes: - cdn:/etc/api/cdn - sql-backups:/etc/api/cdn/host/backup + - seeds:/etc/api/seeds - api-keys:/root/.aspnet/DataProtection-Keys networks: - db diff --git a/ui/src/components/template/Notification.tsx b/ui/src/components/template/Notification.tsx index 81932b71..a2244b61 100644 --- a/ui/src/components/template/Notification.tsx +++ b/ui/src/components/template/Notification.tsx @@ -6,11 +6,14 @@ import ScrollBar from '@/components/ui/ScrollBar' import Spinner from '@/components/ui/Spinner' import Tooltip from '@/components/ui/Tooltip' import { APP_NAME, AVATAR_URL } from '@/constants/app.constant' -import NotificationChannels from '@/constants/notification-channel.enum' +import Tag from '@/components/ui/Tag' +import NotificationChannels, { + NotificationChannelColors, +} from '@/constants/notification-channel.enum' +import { NotificationDto } from '@/proxy/notification/models' import { ROUTES_ENUM } from '@/routes/route.constant' import { getList, - updateRead, updateReadAll, updateReadMany, updateSentMany, @@ -32,8 +35,15 @@ import { Notification as Notify, toast } from '../ui' dayjs.extend(relativeTime) -type NotificationList = { - id: string +type NotificationListItem = { + /** + * Aynı mesaj birden fazla kanalda (UiActivity + UiToast + Mail …) satır + * ürettiği için kayıtlar tek satırda birleştirilir; okundu işaretlemede + * gruptaki tüm id'ler birlikte güncellenir. + */ + ids: string[] + /** Satırın hangi kanallardan geldiği; rozet olarak gösterilir. */ + channels: string[] creatorId: string tenantId?: string notificationType: string @@ -42,8 +52,75 @@ type NotificationList = { readed: boolean } -const notificationHeight = 'h-72' +const notificationHeight = 'h-[26rem]' const notificationInterval = 120000 // 2 minutes +const foregroundPollThrottle = 30000 // 30 seconds + +/** + * Dropdown, Activity Log ekranıyla aynı kapsamı gösterir: kullanıcıya giden her + * kanal listelenir, satırda hangi kanallardan gittiği rozetle belirtilir. + */ +const inboxChannels = Object.values(NotificationChannels) + +/** Tarayıcıda gösterim üretebilen, dolayısıyla isSent takibi yapılan kanallar. */ +const pushChannels: string[] = [NotificationChannels.UiToast, NotificationChannels.Desktop] + +/** Dropdown'da tutulan kayıt sayısı. Tamamı için "View all activity" ekranı var. */ +const inboxSize = 50 + +/** + * Uzun süre uzak kalınıp dönüldüğünde onlarca toast birden açılmasın; en yeniler + * gösterilir, kalanı okunmamış olarak badge ve listede görünür. + */ +const maxPushBurst = 5 + +/** Aynı gönderimden doğan farklı kanal satırlarını eşleştirme toleransı. */ +const duplicateWindowMs = 30000 + +const notificationDate = (value: NotificationDto['creationTime']) => + value instanceof Date ? value.toISOString() : String(value ?? '') + +/** + * Aynı mesajın kanal başına düşen kopyalarını tek satırda toplar. Liste + * `CreationTime desc` sıralı geldiği için kopyalar ardışıktır. + */ +const groupNotifications = (items: NotificationDto[]): NotificationListItem[] => { + const groups: NotificationListItem[] = [] + + for (const item of items) { + const date = notificationDate(item.creationTime) + const time = dayjs(date).valueOf() + + const target = groups.find( + (group) => + group.notificationType === item.notificationType && + group.message === item.message && + Math.abs(dayjs(group.date).valueOf() - time) <= duplicateWindowMs, + ) + + if (target) { + target.ids.push(item.id) + if (!target.channels.includes(item.notificationChannel)) { + target.channels.push(item.notificationChannel) + } + target.readed = target.readed && item.isRead + continue + } + + groups.push({ + ids: [item.id], + channels: [item.notificationChannel], + notificationType: item.notificationType, + message: item.message, + date, + creatorId: item.creatorId as string, + tenantId: item.tenantId, + readed: item.isRead, + }) + } + + return groups +} const NotificationToggle = ({ className, @@ -72,7 +149,7 @@ const NotificationToggle = ({ const _Notification = ({ className }: { className?: string }) => { const { translate } = useLocalization() - const [notificationList, setNotificationList] = useState([]) + const [notificationList, setNotificationList] = useState([]) const [unreadNotificationCount, setUnreadNotificationCount] = useState(0) const [loading, setLoading] = useState(false) @@ -80,25 +157,35 @@ const _Notification = ({ className }: { className?: string }) => { // gösterilmesini engeller. const pushedIds = useRef(new Set()) const listLoaded = useRef(false) + const lastPollAt = useRef(0) const { bgTheme } = useThemeClass() const { larger } = useResponsive() const direction = useStoreState((state) => state.theme.direction) - const tabHasFocus = useStoreState((a) => a.base.common.tabHasFocus) - const tabHasFocusRef = useRef(tabHasFocus) - useEffect(() => { - tabHasFocusRef.current = tabHasFocus - }, [tabHasFocus]) - const getUnreadCount = useCallback(async () => { + /** + * Badge ve dropdown içeriği tek istekten beslenir; böylece rozetteki sayı ile + * listedeki okunmamış kayıt sayısı her zaman aynı kalır. + */ + const getInbox = useCallback(async () => { const resp = await getList({ - channels: [NotificationChannels.UiActivity], - isListRequest: false, - isRead: false, - maxResultCount: 1, + channels: inboxChannels, + // Kanallar arasında UiToast olduğu için servis, geçmişi son 24 saatle + // sınırlayan push filtresini uygulamasın diye liste modunda çağrılır. + isListRequest: true, + sorting: 'CreationTime desc', + maxResultCount: inboxSize, }) - setUnreadNotificationCount(resp.data?.totalCount ?? 0) + + const items = resp.data.items ?? [] + const groups = groupNotifications(items) + + setNotificationList(groups) + setUnreadNotificationCount(groups.filter((a) => !a.readed).length) + listLoaded.current = true + + return items }, []) const getPushNotifications = useCallback(async () => { @@ -112,6 +199,7 @@ const _Notification = ({ className }: { className?: string }) => { channels, isListRequest: false, isSent: false, + sorting: 'CreationTime desc', maxResultCount: 1000, }) @@ -123,7 +211,7 @@ const _Notification = ({ className }: { className?: string }) => { } pending.forEach((a) => pushedIds.current.add(a.id)) - for (const notification of pending) { + for (const notification of pending.slice(0, maxPushBurst)) { if (notification.notificationChannel === NotificationChannels.UiToast) { toast.push( @@ -149,10 +237,15 @@ const _Notification = ({ className }: { className?: string }) => { } } - const processedIds = pending.map((a) => a.id) - await updateSentMany(processedIds, true) - await updateReadMany(processedIds, true) - }, []) + // Yalnızca "gösterildi" işaretlenir; okundu bilgisi kullanıcının kendi + // aksiyonuna bırakılır, aksi hâlde mesaj badge'e hiç düşmezdi. + await updateSentMany( + pending.map((a) => a.id), + true, + ) + + await getInbox() + }, [getInbox]) useEffect(() => { if ('Notification' in window && window.Notification.permission === 'default') { @@ -160,81 +253,81 @@ const _Notification = ({ className }: { className?: string }) => { } const poll = () => { - getUnreadCount() + // Sekme arka plandaysa istek atma. Pencerenin OS odağı yoksa (IDE'ye + // geçmek, ikinci ekran, ayrık DevTools) sekme hâlâ görünür sayılır ve + // yoklama devam eder. + if (document.visibilityState === 'hidden') { + return + } + lastPollAt.current = performance.now() + getInbox() getPushNotifications() } poll() - const intervalId = setInterval(() => { - if (tabHasFocusRef.current) { - poll() + const intervalId = setInterval(poll, notificationInterval) + + // Sekme arka plandan öne geldiğinde bir sonraki tick'i beklemeden tazele; + // hızlı sekme geçişlerinde istek yağmuru olmasın diye kısa aralıklar elenir. + const onForeground = () => { + if (document.visibilityState !== 'visible') { + return } - }, notificationInterval) - - return () => clearInterval(intervalId) - }, [getUnreadCount, getPushNotifications]) - - const onNotificationOpen = useCallback(async () => { - const currentUnread = notificationList.filter((a) => !a.readed).length - if (listLoaded.current && currentUnread === unreadNotificationCount) { - return + if (performance.now() - lastPollAt.current < foregroundPollThrottle) { + return + } + poll() } - setLoading(true) - try { - const resp = await getList({ - channels: [NotificationChannels.UiActivity], - isListRequest: false, - maxResultCount: 1000, - }) - const items = resp.data.items ?? [] - if (items.length > 0) { - await updateSentMany( - items.map((notification) => notification.id), - true, - ) - } + document.addEventListener('visibilitychange', onForeground) + window.addEventListener('focus', onForeground) - setNotificationList( - items.map((a) => ({ - id: a.id, - notificationType: a.notificationType, - date: a.creationTime.toLocaleString(), - message: a.message, - creatorId: a.creatorId, - tenantId: a.tenantId, - readed: a.isRead, - })) as NotificationList[], - ) - listLoaded.current = true + return () => { + clearInterval(intervalId) + document.removeEventListener('visibilitychange', onForeground) + window.removeEventListener('focus', onForeground) + } + }, [getInbox, getPushNotifications]) + + const onNotificationOpen = useCallback(async () => { + setLoading(!listLoaded.current) + try { + const items = await getInbox() + + // Panelde görülen kayıtlar için ayrıca toast/desktop açılmasın. isSent + // yalnızca tarayıcı kanallarında UI'nin sorumluluğunda; Mail/Sms gibi + // kanalların gönderim durumu backend'e ait olduğu için dokunulmaz. + const unsentIds = items + .filter((a) => !a.isSent && pushChannels.includes(a.notificationChannel)) + .map((a) => a.id) + if (unsentIds.length > 0) { + unsentIds.forEach((id) => pushedIds.current.add(id)) + await updateSentMany(unsentIds, true) + } } finally { setLoading(false) } - }, [notificationList, unreadNotificationCount]) + }, [getInbox]) const onMarkAllAsRead = useCallback(async () => { - await updateReadAll(NotificationChannels.UiActivity, true) + await Promise.all(inboxChannels.map((channel) => updateReadAll(channel, true))) setNotificationList((list) => list.map((item) => ({ ...item, readed: true }))) setUnreadNotificationCount(0) }, []) - const onMarkAsRead = useCallback( - async (id: string) => { - const target = notificationList.find((item) => item.id === id) - if (!target || target.readed) { - return - } + const onMarkAsRead = useCallback(async (item: NotificationListItem) => { + if (item.readed) { + return + } - await updateRead(id, true) + await updateReadMany(item.ids, true) - setNotificationList((list) => - list.map((item) => (item.id === id ? { ...item, readed: true } : item)), - ) - setUnreadNotificationCount((count) => Math.max(0, count - 1)) - }, - [notificationList], - ) + setNotificationList((list) => + list.map((a) => (a.ids[0] === item.ids[0] ? { ...a, readed: true } : a)), + ) + setUnreadNotificationCount((count) => Math.max(0, count - 1)) + }, []) const noResult = !loading && listLoaded.current && notificationList.length === 0 @@ -244,7 +337,7 @@ const _Notification = ({ className }: { className?: string }) => { renderTitle={ } - menuClass="p-0 min-w-[280px] md:min-w-[340px]" + menuClass="p-0 min-w-[320px] md:min-w-[440px]" placement={larger.md ? 'bottom-end' : 'bottom-center'} onOpen={onNotificationOpen} > @@ -266,24 +359,36 @@ const _Notification = ({ className }: { className?: string }) => { {notificationList.map((item, index) => (
onMarkAsRead(item.id)} + onClick={() => onMarkAsRead(item)} >
-
+
{item.notificationType && ( {item.notificationType} )}
{item.message}
+
+ {item.channels.map((channel) => ( + + {channel} + + ))} +
{dayjs(item.date).fromNow()}
) => + getTableColumns = ( + dataSourceCode: string, + schemaName: string, + tableName: string, + config?: Partial, + ) => apiService.fetchData( { method: 'GET', @@ -39,7 +44,12 @@ export class SqlObjectManagerService { { apiName: this.apiName, ...config }, ) - getTableCreateScript = (dataSourceCode: string, schemaName: string, tableName: string, config?: Partial) => + getTableCreateScript = ( + dataSourceCode: string, + schemaName: string, + tableName: string, + config?: Partial, + ) => apiService.fetchData( { method: 'GET', @@ -49,7 +59,12 @@ export class SqlObjectManagerService { { apiName: this.apiName, ...config }, ) - getNativeObjectDefinition = (dataSourceCode: string, schemaName: string, objectName: string, config?: Partial) => + getNativeObjectDefinition = ( + dataSourceCode: string, + schemaName: string, + objectName: string, + config?: Partial, + ) => apiService.fetchData( { method: 'GET', @@ -78,42 +93,6 @@ export class SqlObjectManagerService { }, { apiName: this.apiName, ...config }, ) - - getSqlDataFiles = (dataDirectoryName = 'SqlData', relativePath = '', config?: Partial) => - apiService.fetchData< - { fileName: string; name: string; relativePath: string; isDirectory: boolean; createdAt: string }[], - void - >( - { - method: 'GET', - url: '/api/app/sql-object-manager/sql-data-files', - params: { dataDirectoryName, relativePath }, - }, - { apiName: this.apiName, ...config }, - ) - - getSqlDataFileContent = (dataDirectoryName = 'SqlData', relativePath: string, config?: Partial) => - apiService.fetchData( - { - method: 'GET', - url: '/api/app/sql-object-manager/sql-data-file-content', - params: { dataDirectoryName, relativePath }, - }, - { apiName: this.apiName, ...config }, - ) - - moveSqlDataFile = ( - input: { dataDirectoryName: string; sourceRelativePath: string; targetRelativePath: string }, - config?: Partial, - ) => - apiService.fetchData( - { - method: 'POST', - url: '/api/app/sql-object-manager/move-sql-data-file', - data: input, - }, - { apiName: this.apiName, ...config }, - ) } export const sqlObjectManagerService = new SqlObjectManagerService() diff --git a/ui/src/views/admin/files/FileManager.tsx b/ui/src/views/admin/files/FileManager.tsx index 2dd6115e..b184d03b 100644 --- a/ui/src/views/admin/files/FileManager.tsx +++ b/ui/src/views/admin/files/FileManager.tsx @@ -73,6 +73,12 @@ const buildDownloadUrl = (item: FileItemType, fallbackTenantId?: string): string return `${VITE_CDN_URL}/${tenantSegment}/${segments.map(encodeURIComponent).join('/')}` } +/** DbMigrator Seeds klasoru CDN altinda degil, backend sanal kok olarak sunar. */ +const isSeedsItem = (item: FileItemType): boolean => { + const relativePath = item.path || item.id.replace(/\|/g, '/') + return relativePath.split('/')[0]?.toLowerCase() === 'seeds' +} + /** Keyboard shortcuts must not hijack typing inside form controls. */ const isEditableTarget = (target: EventTarget | null): boolean => { const element = target as HTMLElement | null @@ -253,9 +259,7 @@ const FileManager = () => { case 'type': return a.type.localeCompare(b.type) * direction case 'modified': - return ( - (new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime()) * direction - ) + return (new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime()) * direction default: return a.name.localeCompare(b.name) * direction } @@ -360,10 +364,7 @@ const FileManager = () => { try { setRenaming(true) - await fileManagementService.renameItem( - { id: itemToRename.id, newName }, - currentTenantId, - ) + await fileManagementService.renameItem({ id: itemToRename.id, newName }, currentTenantId) // Ids are path based, so the renamed item's id is no longer valid setSelectedItems([]) await refresh() @@ -406,22 +407,41 @@ const FileManager = () => { } }, [currentTenantId, itemsToDelete, notify, refresh]) + const triggerDownload = (href: string, fileName: string) => { + const anchor = document.createElement('a') + anchor.href = href + anchor.download = fileName + anchor.target = '_blank' + anchor.rel = 'noopener noreferrer' + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + } + const handleDownload = useCallback( - (item: FileItemType) => { + async (item: FileItemType) => { + // Sanal Seeds klasoru CDN altinda olmadigi icin dosya API uzerinden stream edilir. + if (isSeedsItem(item)) { + try { + const blob = await fileManagementService.downloadFile(item.id, currentTenantId) + const objectUrl = URL.createObjectURL(blob) + triggerDownload(objectUrl, item.name) + URL.revokeObjectURL(objectUrl) + } catch (error) { + console.error('Download failed:', error) + notify('danger', 'Failed to download file') + } + + return + } + const url = buildDownloadUrl(item, currentTenantId) if (!url) { notify('danger', 'File location is invalid') return } - const anchor = document.createElement('a') - anchor.href = url - anchor.download = item.name - anchor.target = '_blank' - anchor.rel = 'noopener noreferrer' - document.body.appendChild(anchor) - anchor.click() - document.body.removeChild(anchor) + triggerDownload(url, item.name) }, [currentTenantId, notify], ) @@ -889,7 +909,9 @@ const FileManager = () => { icon={allSelected ? : } size="sm" className="text-gray-600 hover:text-blue-600 flex-shrink-0" - title={allSelected ? 'Deselect all selectable items' : 'Select all selectable items'} + title={ + allSelected ? 'Deselect all selectable items' : 'Select all selectable items' + } onClick={allSelected ? deselectAllItems : selectAllItems} > diff --git a/ui/src/views/admin/files/components/FileItem.tsx b/ui/src/views/admin/files/components/FileItem.tsx index 1e706912..fba6cfc0 100644 --- a/ui/src/views/admin/files/components/FileItem.tsx +++ b/ui/src/views/admin/files/components/FileItem.tsx @@ -122,7 +122,7 @@ const getFileTypeLabel = (item: FileItemType, translate: (key: string) => string if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension || '')) return translate('::FileManager.Archive') // 'Arşiv' if (['txt', 'md'].includes(extension || '')) return translate('::FileManager.Text') // 'Metin' - if (['json', 'xml', 'css', 'js', 'ts', 'html'].includes(extension || '')) + if (['xml', 'css', 'js', 'ts', 'html'].includes(extension || '')) return translate('::FileManager.Code') // 'Kod' return extension?.toUpperCase() || translate('::FileManager.File') diff --git a/ui/src/views/developerKit/SqlQueryManager.tsx b/ui/src/views/developerKit/SqlQueryManager.tsx index d7ada267..89bf6981 100644 --- a/ui/src/views/developerKit/SqlQueryManager.tsx +++ b/ui/src/views/developerKit/SqlQueryManager.tsx @@ -1,6 +1,5 @@ import { lazy, Suspense, useState, useCallback, useEffect, useRef } from 'react' -import type { Dispatch, SetStateAction } from 'react' -import { Button, Checkbox, Dialog, Notification, toast } from '@/components/ui' +import { Button, Dialog, Notification, toast } from '@/components/ui' import Container from '@/components/shared/Container' import { getDataSources } from '@/services/data-source.service' import type { DataSourceDto } from '@/proxy/data-source' @@ -13,12 +12,11 @@ import { FaFileAlt, FaCopy, FaExclamationTriangle, - FaArrowLeft, - FaArrowRight, - FaEye, FaCheckCircle, FaFolderOpen, } from 'react-icons/fa' +import { Link } from 'react-router-dom' +import { ROUTES_ENUM } from '@/routes/route.constant' import { useLocalization } from '@/utils/hooks/useLocalization' import SqlObjectExplorer, { type SqlExplorerSelectedObject } from './SqlObjectExplorer' import SqlEditor, { SqlEditorRef } from './SqlEditor' @@ -52,14 +50,6 @@ interface SqlCopyResultItem { message: string } -interface SqlDataExplorerEntry { - fileName: string - name: string - relativePath: string - isDirectory: boolean - createdAt: string -} - const SqlQueryManager = () => { const { translate } = useLocalization() const editorRef = useRef(null) @@ -98,14 +88,6 @@ const SqlQueryManager = () => { const [showCopyResultDialog, setShowCopyResultDialog] = useState(false) const [copyDialogMode, setCopyDialogMode] = useState<'objects' | 'sql'>('objects') const [sqlScriptForCopy, setSqlScriptForCopy] = useState('') - const [showSqlDataFilesDialog, setShowSqlDataFilesDialog] = useState(false) - const [isLoadingSqlDataFiles, setIsLoadingSqlDataFiles] = useState(false) - const [isMovingSqlDataFile, setIsMovingSqlDataFile] = useState(false) - const [isPreviewingSqlDataFile, setIsPreviewingSqlDataFile] = useState(false) - const [sqlDataRootFiles, setSqlDataRootFiles] = useState([]) - const [sqlDataHostFiles, setSqlDataHostFiles] = useState([]) - const [selectedRootSqlDataFiles, setSelectedRootSqlDataFiles] = useState([]) - const [selectedHostSqlDataFiles, setSelectedHostSqlDataFiles] = useState([]) useEffect(() => { loadDataSources() @@ -174,7 +156,6 @@ const SqlQueryManager = () => { (item) => item.code === state.selectedDataSource, )?.dataSourceType const isPostgreSql = selectedDataSourceType === DataSourceTypeEnum.Postgresql - const sqlDataDirectoryName = isPostgreSql ? 'PostgresData' : 'SqlData' const getTableCreateScript = async (schemaName: string, tableName: string): Promise => { if (!state.selectedDataSource) return '' @@ -920,112 +901,6 @@ GO`, const copyErrorCount = copyResults.filter((x) => x.status === 'error').length const copySkippedCount = copyResults.filter((x) => x.status === 'skipped').length - const loadSqlDataFiles = async () => { - setIsLoadingSqlDataFiles(true) - - try { - const [rootResponse, hostResponse] = await Promise.all([ - sqlObjectManagerService.getSqlDataFiles(sqlDataDirectoryName, ''), - sqlObjectManagerService.getSqlDataFiles(sqlDataDirectoryName, 'HostData'), - ]) - - setSqlDataRootFiles(normalizeSqlDataEntries(rootResponse.data || [], '')) - setSqlDataHostFiles(normalizeSqlDataEntries(hostResponse.data || [], 'HostData')) - setSelectedRootSqlDataFiles([]) - setSelectedHostSqlDataFiles([]) - } catch (error: any) { - setSqlDataRootFiles([]) - setSqlDataHostFiles([]) - setSelectedRootSqlDataFiles([]) - setSelectedHostSqlDataFiles([]) - toast.push( - - {error.response?.data?.error?.message || - translate('::App.Platform.FailedToLoadFiles') || - 'SQL dosya listesi yuklenemedi.'} - , - { placement: 'bottom-end' }, - ) - } finally { - setIsLoadingSqlDataFiles(false) - } - } - - const handleOpenSqlDataFilesDialog = async () => { - setShowSqlDataFilesDialog(true) - await loadSqlDataFiles() - } - - const normalizeSqlDataEntries = ( - files: SqlDataExplorerEntry[], - parentRelativePath: '' | 'HostData', - ) => - files - .filter((file) => !file.isDirectory) - .map((file) => { - const fileName = file.name || file.fileName || file.relativePath?.split('/').pop() || '' - const relativePath = - file.relativePath || (parentRelativePath ? `${parentRelativePath}/${fileName}` : fileName) - - return { - ...file, - fileName, - name: fileName, - relativePath, - } - }) - .filter((file) => Boolean(file.name && file.relativePath)) - - const toggleSqlDataFileSelection = ( - setSelectedFiles: Dispatch>, - relativePath: string, - ) => { - setSelectedFiles((current) => - current.includes(relativePath) - ? current.filter((item) => item !== relativePath) - : [...current, relativePath], - ) - } - - const handleMoveSqlDataFiles = async (direction: 'toHostData' | 'toRoot') => { - const selectedRelativePaths = - direction === 'toHostData' ? selectedRootSqlDataFiles : selectedHostSqlDataFiles - - if (selectedRelativePaths.length === 0) { - return - } - - setIsMovingSqlDataFile(true) - - try { - for (const sourceRelativePath of selectedRelativePaths) { - const name = sourceRelativePath.split('/').pop() || sourceRelativePath - await sqlObjectManagerService.moveSqlDataFile({ - dataDirectoryName: sqlDataDirectoryName, - sourceRelativePath, - targetRelativePath: direction === 'toHostData' ? `HostData/${name}` : name, - }) - } - - await loadSqlDataFiles() - toast.push( - - {translate('::App.Platform.OperationCompleted') || 'Dosya tasindi.'} - , - { placement: 'bottom-end' }, - ) - } catch (error: any) { - toast.push( - - {error.response?.data?.error?.message || 'Dosya tasinamadi.'} - , - { placement: 'bottom-end' }, - ) - } finally { - setIsMovingSqlDataFile(false) - } - } - const handleResultsMutation = async (mutationQuery: string) => { if (!state.selectedDataSource || !state.lastExecutedQuery) return @@ -1054,125 +929,6 @@ GO`, } } - const handlePreviewSqlDataFile = async (file: SqlDataExplorerEntry) => { - if (!file.relativePath || isPreviewingSqlDataFile) { - return - } - - setIsPreviewingSqlDataFile(true) - - try { - const response = await sqlObjectManagerService.getSqlDataFileContent( - sqlDataDirectoryName, - file.relativePath, - ) - - setState((prev) => ({ - ...prev, - editorContent: response.data || '', - executionResult: null, - tableColumns: null, - isDirty: false, - })) - setShowSqlDataFilesDialog(false) - - toast.push( - - {translate('::App.SqlQueryManager.ScriptLoadedToEditor') || - 'SQL dosyasi Query Editor icine yuklendi.'} - , - { placement: 'bottom-end' }, - ) - } catch (error: any) { - toast.push( - - {error.response?.data?.error?.message || 'SQL dosyasi okunamadi.'} - , - { placement: 'bottom-end' }, - ) - } finally { - setIsPreviewingSqlDataFile(false) - } - } - - const renderSqlDataPane = ( - title: string, - files: SqlDataExplorerEntry[], - selectedFiles: string[], - setSelectedFiles: Dispatch>, - ) => ( -
-
-
-
{title}
- - {selectedFiles.length}/{files.length} - -
- 0 && selectedFiles.length === files.length} - disabled={files.length === 0 || isMovingSqlDataFile} - onChange={(event) => - setSelectedFiles(event ? files.map((file) => file.relativePath) : []) - } - > - {translate('::FileManager.SelectAll')} - -
-
- {files.length === 0 ? ( -

- {translate('::App.SqlQueryManager.NoSqlDataFiles') || 'Dosya bulunamadi.'} -

- ) : ( -
    - {files.map((file) => { - const selected = selectedFiles.includes(file.relativePath) - - return ( -
  • -
    - - - {new Date(file.createdAt).toLocaleString()} - -
    -
  • - ) - })} -
- )} -
-
- ) - return ( - + {/* Seed dosyalari (DbMigrator/Seeds) File Manager uzerinden yonetilir. */} + + +
@@ -1369,83 +1130,6 @@ GO`,
- setShowSqlDataFilesDialog(false)} - onRequestClose={() => setShowSqlDataFilesDialog(false)} - width={1050} - contentClassName="max-h-[90vh] overflow-hidden" - > - -
-
-
{translate('::App.SqlQueryManager.MoveFiles')}
-
-
- - {isLoadingSqlDataFiles ? ( -

{translate('::App.Loading')}

- ) : ( -
- {renderSqlDataPane( - sqlDataDirectoryName, - sqlDataRootFiles, - selectedRootSqlDataFiles, - setSelectedRootSqlDataFiles, - )} - -
-
- - {renderSqlDataPane( - 'HostData', - sqlDataHostFiles, - selectedHostSqlDataFiles, - setSelectedHostSqlDataFiles, - )} -
- )} -
- - - - - -
- {/* Template Confirmation Dialog */}