Claude güncellemesi SqlQueryManager, Notification, FileManager

This commit is contained in:
Sedat ÖZTÜRK 2026-08-11 22:55:20 +03:00
parent a5ee6de819
commit 783c1fe1d3
32 changed files with 764 additions and 705 deletions

View file

@ -40,19 +40,4 @@ public interface ISqlObjectManagerAppService : IApplicationService
/// Non-existing files are ignored. /// Non-existing files are ignored.
/// </summary> /// </summary>
Task DeleteSqlDataFilesAsync(DeleteSqlDataFilesDto input); Task DeleteSqlDataFilesAsync(DeleteSqlDataFilesDto input);
/// <summary>
/// Lists .sql files currently available under DbMigrator Seeds/SqlData.
/// </summary>
Task<List<SqlDataFileDto>> GetSqlDataFilesAsync(string dataDirectoryName = "SqlData", string relativePath = "");
/// <summary>
/// Reads a .sql seed file content from the selected data directory.
/// </summary>
Task<string> GetSqlDataFileContentAsync(string dataDirectoryName = "SqlData", string relativePath = "");
/// <summary>
/// Moves a SQL seed file between the selected data directory root and HostData.
/// </summary>
Task MoveSqlDataFileAsync(MoveSqlDataFileDto input);
} }

View file

@ -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; }
}

View file

@ -46,10 +46,3 @@ public class DeleteSqlDataFilesDto
/// </summary> /// </summary>
public List<string> FileNames { get; set; } = new(); public List<string> 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;
}

View file

@ -954,107 +954,6 @@ FROM (
return Task.CompletedTask; return Task.CompletedTask;
} }
[HttpGet("api/app/sql-object-manager/sql-data-files")]
public Task<List<SqlDataFileDto>> 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<SqlDataFileDto>());
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<SqlDataFileDto>());
}
}
[HttpGet("api/app/sql-object-manager/sql-data-file-content")]
public async Task<string> 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() private string ResolveSqlDataOutputPath()
{ {
@ -1090,55 +989,4 @@ FROM (
: "SqlData"; : "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}";
}
} }

View file

@ -8,8 +8,10 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Sozsoft.Platform.BlobStoring; using Sozsoft.Platform.BlobStoring;
using Sozsoft.Platform.Permissions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Volo.Abp; using Volo.Abp;
using Volo.Abp.Application.Services; using Volo.Abp.Application.Services;
@ -24,10 +26,20 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
private readonly ICurrentTenant _currentTenant; private readonly ICurrentTenant _currentTenant;
private readonly BlobManager _blobContainer; private readonly BlobManager _blobContainer;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IHostEnvironment _hostEnvironment;
private const string FolderMarkerSuffix = ".folder"; private const string FolderMarkerSuffix = ".folder";
private const string IndexFileName = "index.json"; private const string IndexFileName = "index.json";
/// <summary>
/// CDN altinda fiziksel karsiligi olmayan, DbMigrator projesinin Seeds klasorune baglanan
/// sanal kok klasor. Sadece host baglaminda ve <see cref="PlatformPermissions.Setup.Migrate"/>
/// yetkisi olan kullaniciya gorunur.
/// </summary>
private const string SeedsFolderName = "Seeds";
private const string DbMigratorProjectName = "Sozsoft.Platform.DbMigrator";
private static readonly HashSet<string> ProtectedFolders = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> ProtectedFolders = new(StringComparer.OrdinalIgnoreCase)
{ {
BlobContainerNames.Intranet, BlobContainerNames.Intranet,
@ -35,18 +47,21 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
BlobContainerNames.Import, BlobContainerNames.Import,
BlobContainerNames.Messenger, BlobContainerNames.Messenger,
BlobContainerNames.Note, BlobContainerNames.Note,
BlobContainerNames.Backup BlobContainerNames.Backup,
SeedsFolderName
}; };
public FileManagementAppService( public FileManagementAppService(
ICurrentTenant currentTenant, ICurrentTenant currentTenant,
BlobManager blobContainer, BlobManager blobContainer,
IConfiguration configuration IConfiguration configuration,
IHostEnvironment hostEnvironment
) )
{ {
_currentTenant = currentTenant; _currentTenant = currentTenant;
_blobContainer = blobContainer; _blobContainer = blobContainer;
_configuration = configuration; _configuration = configuration;
_hostEnvironment = hostEnvironment;
} }
private const string HostFolderName = "host"; private const string HostFolderName = "host";
@ -67,6 +82,119 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
: Path.Combine(cdnBasePath, TenantsFolderName, effectiveTenantId); : 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);
}
/// <summary>Host + migration yetkisi olmadan DbMigrator Seeds klasoru ne listelenir ne de degistirilir.</summary>
private async Task<bool> CanAccessSeedsAsync(string tenantId)
{
return IsHostTenant(tenantId) &&
await AuthorizationService.IsGrantedAsync(PlatformPermissions.Setup.Migrate);
}
/// <summary>DbMigrator projesinin Seeds klasorunu bulur (config ile override edilebilir).</summary>
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");
}
/// <summary>
/// Mantiksal yolu (CDN ya da sanal Seeds koku) fiziksel yola cevirir ve kok disina cikilmasini engeller.
/// </summary>
private async Task<string> 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) private static string NormalizeExtension(string? extensionOrFileName)
{ {
if (string.IsNullOrWhiteSpace(extensionOrFileName)) if (string.IsNullOrWhiteSpace(extensionOrFileName))
@ -169,23 +297,34 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
return items; return items;
} }
var fullPath = GetCdnTenantRootPath(cdnBasePath, tenantId); var isRootLevel = string.IsNullOrEmpty(folderPath);
var showSeedsFolder = isRootLevel && await CanAccessSeedsAsync(tenantId);
if (!string.IsNullOrEmpty(folderPath)) var fullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, folderPath);
{
fullPath = Path.Combine(fullPath, ToSystemPath(folderPath));
}
try try
{ {
if (!Directory.Exists(fullPath)) if (!Directory.Exists(fullPath))
{ {
Logger.LogWarning($"Directory does not exist: {fullPath}"); Logger.LogWarning($"Directory does not exist: {fullPath}");
if (showSeedsFolder)
{
AddSeedsFolderItem(items, tenantId);
}
return items; return items;
} }
// Klasörleri listele if (showSeedsFolder)
var directories = Directory.GetDirectories(fullPath); {
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) foreach (var dir in directories)
{ {
var dirInfo = new DirectoryInfo(dir); var dirInfo = new DirectoryInfo(dir);
@ -255,6 +394,24 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
} }
} }
/// <summary>Kok listeye DbMigrator Seeds klasorunu sanal klasor olarak ekler.</summary>
private void AddSeedsFolderItem(List<FileMetadata> 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<FileMetadata> items, string tenantId, string? parentId = null) private async Task SaveFolderIndexAsync(List<FileMetadata> items, string tenantId, string? parentId = null)
{ {
var indexPath = GetTenantPrefix(tenantId) + (string.IsNullOrEmpty(parentId) ? IndexFileName : $"{parentId}/{IndexFileName}"); 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 tenantId = GetEffectiveTenantId(input.TenantId);
var parentPath = GetCdnTenantRootPath(cdnBasePath, tenantId);
string? decodedParentId = null; string? decodedParentId = null;
if (!string.IsNullOrEmpty(input.ParentId)) if (!string.IsNullOrEmpty(input.ParentId))
{ {
decodedParentId = DecodeIdAsPath(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); var folderPath = Path.Combine(parentPath, input.Name);
// Klasör zaten var mı kontrol et // Klasör zaten var mı kontrol et
@ -383,12 +539,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
throw new UserFriendlyException("CDN path is not configured"); throw new UserFriendlyException("CDN path is not configured");
} }
var fullCdnPath = GetCdnTenantRootPath(cdnBasePath, tenantId); var fullCdnPath = await ResolveFullPathAsync(cdnBasePath, tenantId, decodedParentId);
if (!string.IsNullOrEmpty(decodedParentId))
{
fullCdnPath = Path.Combine(fullCdnPath, ToSystemPath(decodedParentId));
}
// Dizini oluştur // Dizini oluştur
Directory.CreateDirectory(fullCdnPath); Directory.CreateDirectory(fullCdnPath);
@ -556,7 +707,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
var effectiveTenantId = GetEffectiveTenantId(tenantId); var effectiveTenantId = GetEffectiveTenantId(tenantId);
var actualPath = DecodeIdAsPath(id); var actualPath = DecodeIdAsPath(id);
var fullPath = Path.Combine(GetCdnTenantRootPath(cdnBasePath, effectiveTenantId), ToSystemPath(actualPath)); var fullPath = await ResolveFullPathAsync(cdnBasePath, effectiveTenantId, actualPath);
if (Directory.Exists(fullPath)) if (Directory.Exists(fullPath))
{ {
@ -599,7 +750,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
ValidateNotProtectedFolder(itemId, "delete"); ValidateNotProtectedFolder(itemId, "delete");
var actualPath = DecodeIdAsPath(itemId); var actualPath = DecodeIdAsPath(itemId);
var fullPath = Path.Combine(GetCdnTenantRootPath(cdnBasePath, tenantId), ToSystemPath(actualPath)); var fullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, actualPath);
if (Directory.Exists(fullPath)) if (Directory.Exists(fullPath))
{ {
@ -643,7 +794,6 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
} }
var tenantId = GetEffectiveTenantId(input.TenantId); var tenantId = GetEffectiveTenantId(input.TenantId);
var basePath = GetCdnTenantRootPath(cdnBasePath, tenantId);
string? targetPath = null; string? targetPath = null;
if (!string.IsNullOrEmpty(input.TargetFolderId)) if (!string.IsNullOrEmpty(input.TargetFolderId))
@ -659,17 +809,17 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
try try
{ {
var sourcePath = DecodeIdAsPath(itemId); var sourcePath = DecodeIdAsPath(itemId);
var sourceFullPath = Path.Combine(basePath, ToSystemPath(sourcePath)); var sourceFullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, sourcePath);
// Get source item name // Get source item name
var sourceItemName = Path.GetFileName(sourcePath); var sourceItemName = Path.GetFileName(sourcePath);
// Generate unique name if item already exists in target // Generate unique name if item already exists in target
var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}"; 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 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)) if (Directory.Exists(sourceFullPath))
{ {
@ -756,7 +906,6 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
} }
var tenantId = GetEffectiveTenantId(input.TenantId); var tenantId = GetEffectiveTenantId(input.TenantId);
var basePath = GetCdnTenantRootPath(cdnBasePath, tenantId);
string? targetPath = null; string? targetPath = null;
if (!string.IsNullOrEmpty(input.TargetFolderId)) if (!string.IsNullOrEmpty(input.TargetFolderId))
@ -775,14 +924,14 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
ValidateNotProtectedFolder(itemId, "move"); ValidateNotProtectedFolder(itemId, "move");
var sourcePath = DecodeIdAsPath(itemId); var sourcePath = DecodeIdAsPath(itemId);
var sourceFullPath = Path.Combine(basePath, ToSystemPath(sourcePath)); var sourceFullPath = await ResolveFullPathAsync(cdnBasePath, tenantId, sourcePath);
// Get source item name // Get source item name
var sourceItemName = Path.GetFileName(sourcePath); var sourceItemName = Path.GetFileName(sourcePath);
// Generate target path // Generate target path
var targetItemPath = string.IsNullOrEmpty(targetPath) ? sourceItemName : $"{targetPath}/{sourceItemName}"; 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 // Check if moving to same location
if (Path.GetFullPath(sourceFullPath) == Path.GetFullPath(targetFullPath)) 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 // Generate unique name if item already exists in target
var uniqueTargetPath = GetUniqueItemPath(targetFullPath, sourceItemName); 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)) if (Directory.Exists(sourceFullPath))
{ {
@ -882,7 +1031,7 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
var effectiveTenantId = GetEffectiveTenantId(tenantId); var effectiveTenantId = GetEffectiveTenantId(tenantId);
var actualPath = DecodeIdAsPath(id); var actualPath = DecodeIdAsPath(id);
var fullFilePath = Path.Combine(GetCdnTenantRootPath(cdnBasePath, effectiveTenantId), ToSystemPath(actualPath)); var fullFilePath = await ResolveFullPathAsync(cdnBasePath, effectiveTenantId, actualPath);
if (!File.Exists(fullFilePath)) if (!File.Exists(fullFilePath))
{ {
@ -1077,6 +1226,11 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
return new FolderPathDto { Path = pathItems }; 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) private string GetUniqueItemPath(string targetPath, string originalName)
{ {
if (!File.Exists(targetPath) && !Directory.Exists(targetPath)) if (!File.Exists(targetPath) && !Directory.Exists(targetPath))

View file

@ -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;
/// <summary>
/// Seeds/{SqlData|PostgresData}/{HostData|TenantData}/ExecuteData klasöründeki .sql dosyaları
/// iki aşamada işlenir:
/// 1) Migration/seed sırasında <see cref="SqlDataSeeder"/> 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.
/// </summary>
public class AfterAllMigrationsSqlExecutor : IAfterAllMigrationsSqlExecutor, ITransientDependency
{
/// <summary>HostData/TenantData altındaki, migration sonrası da çalıştırılacak script klasörünün adı.</summary>
public const string DirectoryName = "ExecuteData";
/// <summary>Host kapsamındaki script klasörü.</summary>
public const string HostDataDirectoryName = "HostData";
/// <summary>Tenant kapsamındaki script klasörü (henüz kullanılmıyorsa sessizce atlanır).</summary>
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<PlatformDbContext> _dbContextProvider;
private readonly IUnitOfWorkManager _unitOfWorkManager;
private readonly ILogger<AfterAllMigrationsSqlExecutor> _logger;
public AfterAllMigrationsSqlExecutor(
IDbContextProvider<PlatformDbContext> dbContextProvider,
IUnitOfWorkManager unitOfWorkManager,
ILogger<AfterAllMigrationsSqlExecutor> 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<string>();
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);
}
/// <summary>
/// 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.
/// </summary>
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.';";
}
}

View file

@ -111,18 +111,28 @@ public class SqlDataSeeder : IDataSeedContributor, ITransientDependency
: "SqlData"; : "SqlData";
} }
private static string[] GetSqlFiles(string dataDirectoryPath, bool includeHostData) private static string[] GetSqlFiles(string dataDirectoryPath, bool isHostSeed)
{ {
var sqlFiles = Directory.GetFiles(dataDirectoryPath, "*.sql") var sqlFiles = Directory.GetFiles(dataDirectoryPath, "*.sql")
.OrderBy(f => Path.GetFileName(f)) .OrderBy(f => Path.GetFileName(f))
.ToList(); .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"); sqlFiles.AddRange(Directory.GetFiles(scopePath, "*.sql")
if (Directory.Exists(hostDataPath)) .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))); .OrderBy(f => Path.GetFileName(f)));
} }
} }

View file

@ -18440,21 +18440,15 @@
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.SqlQueryManager.MoveFiles", "key": "App.SqlQueryManager.OpenFileManager",
"en": "Move to Host Folder", "en": "Seed Files",
"tr": "Host Klasörüne Taşı" "tr": "Seed Dosyaları"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.SqlQueryManager.MoveToHostData", "key": "App.SqlQueryManager.OpenFileManagerHint",
"en": "Move to Host Data", "en": "Manage DbMigrator Seeds folder in File Manager",
"tr": "Host Klasörüne Taşı" "tr": "DbMigrator Seeds klasörünü Dosya Yöneticisi'nde yönet"
},
{
"resourceName": "Platform",
"key": "App.SqlQueryManager.MoveOut",
"en": "Move Out",
"tr": "Dışarı Taşı"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",

View file

@ -89,6 +89,18 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Seeds\SqlData\HostData\ExecuteData\*.sql">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Seeds\SqlData\TenantData\*.sql">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Seeds\SqlData\TenantData\ExecuteData\*.sql">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Seeds\PostgresData\*.sql"> <Content Include="Seeds\PostgresData\*.sql">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
@ -97,6 +109,18 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Seeds\PostgresData\HostData\ExecuteData\*.sql">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Seeds\PostgresData\TenantData\*.sql">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Seeds\PostgresData\TenantData\ExecuteData\*.sql">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
namespace Sozsoft.Platform.Data;
/// <summary>
/// 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.
/// </summary>
public interface IAfterAllMigrationsSqlExecutor
{
/// <param name="tenantId">
/// null ise HostData/ExecuteData, aksi halde TenantData/ExecuteData klasörü işlenir.
/// </param>
Task ExecuteAsync(Guid? tenantId = null);
}

View file

@ -26,19 +26,22 @@ public class PlatformDbMigrationService : ITransientDependency
private readonly ITenantRepository _tenantRepository; private readonly ITenantRepository _tenantRepository;
private readonly ICurrentTenant _currentTenant; private readonly ICurrentTenant _currentTenant;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IEnumerable<IAfterAllMigrationsSqlExecutor> _afterAllMigrationsSqlExecutors;
public PlatformDbMigrationService( public PlatformDbMigrationService(
IDataSeeder dataSeeder, IDataSeeder dataSeeder,
IEnumerable<IPlatformDbSchemaMigrator> dbSchemaMigrators, IEnumerable<IPlatformDbSchemaMigrator> dbSchemaMigrators,
ITenantRepository tenantRepository, ITenantRepository tenantRepository,
ICurrentTenant currentTenant, ICurrentTenant currentTenant,
IConfiguration configuration) IConfiguration configuration,
IEnumerable<IAfterAllMigrationsSqlExecutor> afterAllMigrationsSqlExecutors)
{ {
_dataSeeder = dataSeeder; _dataSeeder = dataSeeder;
_dbSchemaMigrators = dbSchemaMigrators; _dbSchemaMigrators = dbSchemaMigrators;
_tenantRepository = tenantRepository; _tenantRepository = tenantRepository;
_currentTenant = currentTenant; _currentTenant = currentTenant;
_configuration = configuration; _configuration = configuration;
_afterAllMigrationsSqlExecutors = afterAllMigrationsSqlExecutors;
Logger = NullLogger<PlatformDbMigrationService>.Instance; Logger = NullLogger<PlatformDbMigrationService>.Instance;
} }
@ -100,9 +103,59 @@ public class PlatformDbMigrationService : ITransientDependency
} }
Logger.LogInformation("Successfully completed all database migrations."); Logger.LogInformation("Successfully completed all database migrations.");
await ExecuteAfterAllMigrationsSqlAsync(tenants);
Logger.LogInformation("You can safely end this process..."); Logger.LogInformation("You can safely end this process...");
} }
/// <summary>
/// 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.
/// </summary>
private async Task ExecuteAfterAllMigrationsSqlAsync(List<Tenant> 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) private async Task MigrateDatabaseSchemaAsync(Tenant? tenant = null)
{ {
Logger.LogInformation( Logger.LogInformation(

View file

@ -7,6 +7,7 @@
"AttachmentsPath": "/etc/api/mail-queue/attachments", "AttachmentsPath": "/etc/api/mail-queue/attachments",
"CdnUrl": "https://dev-cdn.sozsoft.com", "CdnUrl": "https://dev-cdn.sozsoft.com",
"CdnPath": "/etc/api/cdn", "CdnPath": "/etc/api/cdn",
"DbMigratorSeedsPath": "/etc/api/seeds",
"BaseDomain": "sozsoft.com", "BaseDomain": "sozsoft.com",
"BackupPath": "/var/opt/mssql/backup" "BackupPath": "/var/opt/mssql/backup"
}, },

View file

@ -7,6 +7,7 @@
"AttachmentsPath": "/etc/api/mail-queue/attachments", "AttachmentsPath": "/etc/api/mail-queue/attachments",
"CdnUrl": "https://cdn.sozsoft.com", "CdnUrl": "https://cdn.sozsoft.com",
"CdnPath": "/etc/api/cdn", "CdnPath": "/etc/api/cdn",
"DbMigratorSeedsPath": "/etc/api/seeds",
"BaseDomain": "sozsoft.com", "BaseDomain": "sozsoft.com",
"BackupPath": "/var/opt/mssql/backup" "BackupPath": "/var/opt/mssql/backup"
}, },

View file

@ -8,6 +8,9 @@ networks:
volumes: volumes:
cdn: cdn:
api-keys: 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: sql-backups:
external: true external: true
name: sozsoft-platform-data_sql-backups name: sozsoft-platform-data_sql-backups
@ -21,6 +24,8 @@ services:
environment: environment:
- ASPNETCORE_ENVIRONMENT=Dev - ASPNETCORE_ENVIRONMENT=Dev
- SEED=${SEED} - SEED=${SEED}
volumes:
- seeds:/srv/app/Seeds
networks: networks:
- db - db
@ -35,6 +40,7 @@ services:
volumes: volumes:
- cdn:/etc/api/cdn - cdn:/etc/api/cdn
- sql-backups:/etc/api/cdn/host/backup - sql-backups:/etc/api/cdn/host/backup
- seeds:/etc/api/seeds
- api-keys:/root/.aspnet/DataProtection-Keys - api-keys:/root/.aspnet/DataProtection-Keys
networks: networks:
- db - db

View file

@ -6,11 +6,14 @@ import ScrollBar from '@/components/ui/ScrollBar'
import Spinner from '@/components/ui/Spinner' import Spinner from '@/components/ui/Spinner'
import Tooltip from '@/components/ui/Tooltip' import Tooltip from '@/components/ui/Tooltip'
import { APP_NAME, AVATAR_URL } from '@/constants/app.constant' 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 { ROUTES_ENUM } from '@/routes/route.constant'
import { import {
getList, getList,
updateRead,
updateReadAll, updateReadAll,
updateReadMany, updateReadMany,
updateSentMany, updateSentMany,
@ -32,8 +35,15 @@ import { Notification as Notify, toast } from '../ui'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
type NotificationList = { type NotificationListItem = {
id: string /**
* 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 creatorId: string
tenantId?: string tenantId?: string
notificationType: string notificationType: string
@ -42,8 +52,75 @@ type NotificationList = {
readed: boolean readed: boolean
} }
const notificationHeight = 'h-72' const notificationHeight = 'h-[26rem]'
const notificationInterval = 120000 // 2 minutes 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 ı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 = ({ const NotificationToggle = ({
className, className,
@ -72,7 +149,7 @@ const NotificationToggle = ({
const _Notification = ({ className }: { className?: string }) => { const _Notification = ({ className }: { className?: string }) => {
const { translate } = useLocalization() const { translate } = useLocalization()
const [notificationList, setNotificationList] = useState<NotificationList[]>([]) const [notificationList, setNotificationList] = useState<NotificationListItem[]>([])
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0) const [unreadNotificationCount, setUnreadNotificationCount] = useState(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -80,25 +157,35 @@ const _Notification = ({ className }: { className?: string }) => {
// gösterilmesini engeller. // gösterilmesini engeller.
const pushedIds = useRef(new Set<string>()) const pushedIds = useRef(new Set<string>())
const listLoaded = useRef(false) const listLoaded = useRef(false)
const lastPollAt = useRef(0)
const { bgTheme } = useThemeClass() const { bgTheme } = useThemeClass()
const { larger } = useResponsive() const { larger } = useResponsive()
const direction = useStoreState((state) => state.theme.direction) 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({ const resp = await getList({
channels: [NotificationChannels.UiActivity], channels: inboxChannels,
isListRequest: false, // Kanallar arasında UiToast olduğu için servis, geçmişi son 24 saatle
isRead: false, // sınırlayan push filtresini uygulamasın diye liste modunda çağrılır.
maxResultCount: 1, 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 () => { const getPushNotifications = useCallback(async () => {
@ -112,6 +199,7 @@ const _Notification = ({ className }: { className?: string }) => {
channels, channels,
isListRequest: false, isListRequest: false,
isSent: false, isSent: false,
sorting: 'CreationTime desc',
maxResultCount: 1000, maxResultCount: 1000,
}) })
@ -123,7 +211,7 @@ const _Notification = ({ className }: { className?: string }) => {
} }
pending.forEach((a) => pushedIds.current.add(a.id)) 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) { if (notification.notificationChannel === NotificationChannels.UiToast) {
toast.push( toast.push(
<Notify type="success" duration={0} closable={true}> <Notify type="success" duration={0} closable={true}>
@ -149,10 +237,15 @@ const _Notification = ({ className }: { className?: string }) => {
} }
} }
const processedIds = pending.map((a) => a.id) // Yalnızca "gösterildi" işaretlenir; okundu bilgisi kullanıcının kendi
await updateSentMany(processedIds, true) // aksiyonuna bırakılır, aksi hâlde mesaj badge'e hiç düşmezdi.
await updateReadMany(processedIds, true) await updateSentMany(
}, []) pending.map((a) => a.id),
true,
)
await getInbox()
}, [getInbox])
useEffect(() => { useEffect(() => {
if ('Notification' in window && window.Notification.permission === 'default') { if ('Notification' in window && window.Notification.permission === 'default') {
@ -160,81 +253,81 @@ const _Notification = ({ className }: { className?: string }) => {
} }
const poll = () => { 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() getPushNotifications()
} }
poll() poll()
const intervalId = setInterval(() => { const intervalId = setInterval(poll, notificationInterval)
if (tabHasFocusRef.current) {
poll() // 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) if (performance.now() - lastPollAt.current < foregroundPollThrottle) {
return
return () => clearInterval(intervalId) }
}, [getUnreadCount, getPushNotifications]) poll()
const onNotificationOpen = useCallback(async () => {
const currentUnread = notificationList.filter((a) => !a.readed).length
if (listLoaded.current && currentUnread === unreadNotificationCount) {
return
} }
setLoading(true) document.addEventListener('visibilitychange', onForeground)
try { window.addEventListener('focus', onForeground)
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,
)
}
setNotificationList( return () => {
items.map((a) => ({ clearInterval(intervalId)
id: a.id, document.removeEventListener('visibilitychange', onForeground)
notificationType: a.notificationType, window.removeEventListener('focus', onForeground)
date: a.creationTime.toLocaleString(), }
message: a.message, }, [getInbox, getPushNotifications])
creatorId: a.creatorId,
tenantId: a.tenantId, const onNotificationOpen = useCallback(async () => {
readed: a.isRead, setLoading(!listLoaded.current)
})) as NotificationList[], try {
) const items = await getInbox()
listLoaded.current = true
// 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 { } finally {
setLoading(false) setLoading(false)
} }
}, [notificationList, unreadNotificationCount]) }, [getInbox])
const onMarkAllAsRead = useCallback(async () => { 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 }))) setNotificationList((list) => list.map((item) => ({ ...item, readed: true })))
setUnreadNotificationCount(0) setUnreadNotificationCount(0)
}, []) }, [])
const onMarkAsRead = useCallback( const onMarkAsRead = useCallback(async (item: NotificationListItem) => {
async (id: string) => { if (item.readed) {
const target = notificationList.find((item) => item.id === id) return
if (!target || target.readed) { }
return
}
await updateRead(id, true) await updateReadMany(item.ids, true)
setNotificationList((list) => setNotificationList((list) =>
list.map((item) => (item.id === id ? { ...item, readed: true } : item)), list.map((a) => (a.ids[0] === item.ids[0] ? { ...a, readed: true } : a)),
) )
setUnreadNotificationCount((count) => Math.max(0, count - 1)) setUnreadNotificationCount((count) => Math.max(0, count - 1))
}, }, [])
[notificationList],
)
const noResult = !loading && listLoaded.current && notificationList.length === 0 const noResult = !loading && listLoaded.current && notificationList.length === 0
@ -244,7 +337,7 @@ const _Notification = ({ className }: { className?: string }) => {
renderTitle={ renderTitle={
<NotificationToggle unreadCount={unreadNotificationCount} className={className} /> <NotificationToggle unreadCount={unreadNotificationCount} className={className} />
} }
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'} placement={larger.md ? 'bottom-end' : 'bottom-center'}
onOpen={onNotificationOpen} onOpen={onNotificationOpen}
> >
@ -266,24 +359,36 @@ const _Notification = ({ className }: { className?: string }) => {
<ScrollBar direction={direction}> <ScrollBar direction={direction}>
{notificationList.map((item, index) => ( {notificationList.map((item, index) => (
<div <div
key={item.id} key={item.ids[0]}
className={`relative flex px-4 py-4 cursor-pointer hover:bg-gray-50 active:bg-gray-100 dark:hover:bg-black dark:hover:bg-opacity-20 ${ className={`relative flex px-4 py-4 cursor-pointer hover:bg-gray-50 active:bg-gray-100 dark:hover:bg-black dark:hover:bg-opacity-20 ${
!isLastChild(notificationList, index) !isLastChild(notificationList, index)
? 'border-b border-gray-200 dark:border-gray-600' ? 'border-b border-gray-200 dark:border-gray-600'
: '' : ''
}`} }`}
onClick={() => onMarkAsRead(item.id)} onClick={() => onMarkAsRead(item)}
> >
<div> <div>
<Avatar shape="circle" src={AVATAR_URL(item.creatorId, item.tenantId)} /> <Avatar shape="circle" src={AVATAR_URL(item.creatorId, item.tenantId)} />
</div> </div>
<div className="ltr:ml-3 rtl:mr-3"> <div className="ltr:ml-3 rtl:mr-3 ltr:mr-6 rtl:ml-6 min-w-0">
<div> <div>
{item.notificationType && ( {item.notificationType && (
<span className="font-semibold heading-text">{item.notificationType} </span> <span className="font-semibold heading-text">{item.notificationType} </span>
)} )}
<div>{item.message}</div> <div>{item.message}</div>
</div> </div>
<div className="mt-1 flex flex-wrap items-center gap-1">
{item.channels.map((channel) => (
<Tag
key={channel}
prefix
className="text-xs"
prefixClass={NotificationChannelColors[channel]}
>
{channel}
</Tag>
))}
</div>
<span className="text-xs">{dayjs(item.date).fromNow()}</span> <span className="text-xs">{dayjs(item.date).fromNow()}</span>
</div> </div>
<Badge <Badge

View file

@ -29,7 +29,12 @@ export class SqlObjectManagerService {
{ apiName: this.apiName, ...config }, { apiName: this.apiName, ...config },
) )
getTableColumns = (dataSourceCode: string, schemaName: string, tableName: string, config?: Partial<Config>) => getTableColumns = (
dataSourceCode: string,
schemaName: string,
tableName: string,
config?: Partial<Config>,
) =>
apiService.fetchData<DatabaseColumnDto[], void>( apiService.fetchData<DatabaseColumnDto[], void>(
{ {
method: 'GET', method: 'GET',
@ -39,7 +44,12 @@ export class SqlObjectManagerService {
{ apiName: this.apiName, ...config }, { apiName: this.apiName, ...config },
) )
getTableCreateScript = (dataSourceCode: string, schemaName: string, tableName: string, config?: Partial<Config>) => getTableCreateScript = (
dataSourceCode: string,
schemaName: string,
tableName: string,
config?: Partial<Config>,
) =>
apiService.fetchData<string, void>( apiService.fetchData<string, void>(
{ {
method: 'GET', method: 'GET',
@ -49,7 +59,12 @@ export class SqlObjectManagerService {
{ apiName: this.apiName, ...config }, { apiName: this.apiName, ...config },
) )
getNativeObjectDefinition = (dataSourceCode: string, schemaName: string, objectName: string, config?: Partial<Config>) => getNativeObjectDefinition = (
dataSourceCode: string,
schemaName: string,
objectName: string,
config?: Partial<Config>,
) =>
apiService.fetchData<string, void>( apiService.fetchData<string, void>(
{ {
method: 'GET', method: 'GET',
@ -78,42 +93,6 @@ export class SqlObjectManagerService {
}, },
{ apiName: this.apiName, ...config }, { apiName: this.apiName, ...config },
) )
getSqlDataFiles = (dataDirectoryName = 'SqlData', relativePath = '', config?: Partial<Config>) =>
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<Config>) =>
apiService.fetchData<string, void>(
{
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<Config>,
) =>
apiService.fetchData<void, { dataDirectoryName: string; sourceRelativePath: string; targetRelativePath: string }>(
{
method: 'POST',
url: '/api/app/sql-object-manager/move-sql-data-file',
data: input,
},
{ apiName: this.apiName, ...config },
)
} }
export const sqlObjectManagerService = new SqlObjectManagerService() export const sqlObjectManagerService = new SqlObjectManagerService()

View file

@ -73,6 +73,12 @@ const buildDownloadUrl = (item: FileItemType, fallbackTenantId?: string): string
return `${VITE_CDN_URL}/${tenantSegment}/${segments.map(encodeURIComponent).join('/')}` 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. */ /** Keyboard shortcuts must not hijack typing inside form controls. */
const isEditableTarget = (target: EventTarget | null): boolean => { const isEditableTarget = (target: EventTarget | null): boolean => {
const element = target as HTMLElement | null const element = target as HTMLElement | null
@ -253,9 +259,7 @@ const FileManager = () => {
case 'type': case 'type':
return a.type.localeCompare(b.type) * direction return a.type.localeCompare(b.type) * direction
case 'modified': case 'modified':
return ( return (new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime()) * direction
(new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime()) * direction
)
default: default:
return a.name.localeCompare(b.name) * direction return a.name.localeCompare(b.name) * direction
} }
@ -360,10 +364,7 @@ const FileManager = () => {
try { try {
setRenaming(true) setRenaming(true)
await fileManagementService.renameItem( await fileManagementService.renameItem({ id: itemToRename.id, newName }, currentTenantId)
{ id: itemToRename.id, newName },
currentTenantId,
)
// Ids are path based, so the renamed item's id is no longer valid // Ids are path based, so the renamed item's id is no longer valid
setSelectedItems([]) setSelectedItems([])
await refresh() await refresh()
@ -406,22 +407,41 @@ const FileManager = () => {
} }
}, [currentTenantId, itemsToDelete, notify, refresh]) }, [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( 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) const url = buildDownloadUrl(item, currentTenantId)
if (!url) { if (!url) {
notify('danger', 'File location is invalid') notify('danger', 'File location is invalid')
return return
} }
const anchor = document.createElement('a') triggerDownload(url, item.name)
anchor.href = url
anchor.download = item.name
anchor.target = '_blank'
anchor.rel = 'noopener noreferrer'
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
}, },
[currentTenantId, notify], [currentTenantId, notify],
) )
@ -889,7 +909,9 @@ const FileManager = () => {
icon={allSelected ? <FaCheckSquare /> : <FaSquare />} icon={allSelected ? <FaCheckSquare /> : <FaSquare />}
size="sm" size="sm"
className="text-gray-600 hover:text-blue-600 flex-shrink-0" 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} onClick={allSelected ? deselectAllItems : selectAllItems}
> >
<span className="hidden lg:inline"> <span className="hidden lg:inline">

View file

@ -122,7 +122,7 @@ const getFileTypeLabel = (item: FileItemType, translate: (key: string) => string
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension || '')) if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension || ''))
return translate('::FileManager.Archive') // 'Arşiv' return translate('::FileManager.Archive') // 'Arşiv'
if (['txt', 'md'].includes(extension || '')) return translate('::FileManager.Text') // 'Metin' 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 translate('::FileManager.Code') // 'Kod'
return extension?.toUpperCase() || translate('::FileManager.File') return extension?.toUpperCase() || translate('::FileManager.File')

View file

@ -1,6 +1,5 @@
import { lazy, Suspense, useState, useCallback, useEffect, useRef } from 'react' import { lazy, Suspense, useState, useCallback, useEffect, useRef } from 'react'
import type { Dispatch, SetStateAction } from 'react' import { Button, Dialog, Notification, toast } from '@/components/ui'
import { Button, Checkbox, Dialog, Notification, toast } from '@/components/ui'
import Container from '@/components/shared/Container' import Container from '@/components/shared/Container'
import { getDataSources } from '@/services/data-source.service' import { getDataSources } from '@/services/data-source.service'
import type { DataSourceDto } from '@/proxy/data-source' import type { DataSourceDto } from '@/proxy/data-source'
@ -13,12 +12,11 @@ import {
FaFileAlt, FaFileAlt,
FaCopy, FaCopy,
FaExclamationTriangle, FaExclamationTriangle,
FaArrowLeft,
FaArrowRight,
FaEye,
FaCheckCircle, FaCheckCircle,
FaFolderOpen, FaFolderOpen,
} from 'react-icons/fa' } from 'react-icons/fa'
import { Link } from 'react-router-dom'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import SqlObjectExplorer, { type SqlExplorerSelectedObject } from './SqlObjectExplorer' import SqlObjectExplorer, { type SqlExplorerSelectedObject } from './SqlObjectExplorer'
import SqlEditor, { SqlEditorRef } from './SqlEditor' import SqlEditor, { SqlEditorRef } from './SqlEditor'
@ -52,14 +50,6 @@ interface SqlCopyResultItem {
message: string message: string
} }
interface SqlDataExplorerEntry {
fileName: string
name: string
relativePath: string
isDirectory: boolean
createdAt: string
}
const SqlQueryManager = () => { const SqlQueryManager = () => {
const { translate } = useLocalization() const { translate } = useLocalization()
const editorRef = useRef<SqlEditorRef>(null) const editorRef = useRef<SqlEditorRef>(null)
@ -98,14 +88,6 @@ const SqlQueryManager = () => {
const [showCopyResultDialog, setShowCopyResultDialog] = useState(false) const [showCopyResultDialog, setShowCopyResultDialog] = useState(false)
const [copyDialogMode, setCopyDialogMode] = useState<'objects' | 'sql'>('objects') const [copyDialogMode, setCopyDialogMode] = useState<'objects' | 'sql'>('objects')
const [sqlScriptForCopy, setSqlScriptForCopy] = useState('') 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<SqlDataExplorerEntry[]>([])
const [sqlDataHostFiles, setSqlDataHostFiles] = useState<SqlDataExplorerEntry[]>([])
const [selectedRootSqlDataFiles, setSelectedRootSqlDataFiles] = useState<string[]>([])
const [selectedHostSqlDataFiles, setSelectedHostSqlDataFiles] = useState<string[]>([])
useEffect(() => { useEffect(() => {
loadDataSources() loadDataSources()
@ -174,7 +156,6 @@ const SqlQueryManager = () => {
(item) => item.code === state.selectedDataSource, (item) => item.code === state.selectedDataSource,
)?.dataSourceType )?.dataSourceType
const isPostgreSql = selectedDataSourceType === DataSourceTypeEnum.Postgresql const isPostgreSql = selectedDataSourceType === DataSourceTypeEnum.Postgresql
const sqlDataDirectoryName = isPostgreSql ? 'PostgresData' : 'SqlData'
const getTableCreateScript = async (schemaName: string, tableName: string): Promise<string> => { const getTableCreateScript = async (schemaName: string, tableName: string): Promise<string> => {
if (!state.selectedDataSource) return '' if (!state.selectedDataSource) return ''
@ -920,112 +901,6 @@ GO`,
const copyErrorCount = copyResults.filter((x) => x.status === 'error').length const copyErrorCount = copyResults.filter((x) => x.status === 'error').length
const copySkippedCount = copyResults.filter((x) => x.status === 'skipped').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(
<Notification type="danger" title={translate('::App.Platform.Error')}>
{error.response?.data?.error?.message ||
translate('::App.Platform.FailedToLoadFiles') ||
'SQL dosya listesi yuklenemedi.'}
</Notification>,
{ 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<SetStateAction<string[]>>,
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(
<Notification type="success" title={translate('::App.Platform.Success')}>
{translate('::App.Platform.OperationCompleted') || 'Dosya tasindi.'}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error: any) {
toast.push(
<Notification type="danger" title={translate('::App.Platform.Error')}>
{error.response?.data?.error?.message || 'Dosya tasinamadi.'}
</Notification>,
{ placement: 'bottom-end' },
)
} finally {
setIsMovingSqlDataFile(false)
}
}
const handleResultsMutation = async (mutationQuery: string) => { const handleResultsMutation = async (mutationQuery: string) => {
if (!state.selectedDataSource || !state.lastExecutedQuery) return 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(
<Notification type="success" title={translate('::App.Platform.Success')}>
{translate('::App.SqlQueryManager.ScriptLoadedToEditor') ||
'SQL dosyasi Query Editor icine yuklendi.'}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error: any) {
toast.push(
<Notification type="danger" title={translate('::App.Platform.Error')}>
{error.response?.data?.error?.message || 'SQL dosyasi okunamadi.'}
</Notification>,
{ placement: 'bottom-end' },
)
} finally {
setIsPreviewingSqlDataFile(false)
}
}
const renderSqlDataPane = (
title: string,
files: SqlDataExplorerEntry[],
selectedFiles: string[],
setSelectedFiles: Dispatch<SetStateAction<string[]>>,
) => (
<div className="flex min-h-[260px] flex-1 flex-col overflow-hidden rounded border border-gray-200 dark:border-gray-700">
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center gap-2">
<h6 className="text-sm font-semibold text-gray-800 dark:text-gray-100">{title}</h6>
<span className="text-xs text-gray-500 dark:text-gray-400">
{selectedFiles.length}/{files.length}
</span>
</div>
<Checkbox
checked={files.length > 0 && selectedFiles.length === files.length}
disabled={files.length === 0 || isMovingSqlDataFile}
onChange={(event) =>
setSelectedFiles(event ? files.map((file) => file.relativePath) : [])
}
>
{translate('::FileManager.SelectAll')}
</Checkbox>
</div>
<div className="flex-1 overflow-y-auto">
{files.length === 0 ? (
<p className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{translate('::App.SqlQueryManager.NoSqlDataFiles') || 'Dosya bulunamadi.'}
</p>
) : (
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{files.map((file) => {
const selected = selectedFiles.includes(file.relativePath)
return (
<li key={file.relativePath || file.fileName}>
<div
className={`flex cursor-pointer items-center gap-3 px-3 py-2 text-xs ${
selected
? 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-200'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800'
}`}
>
<label className="flex min-w-0 flex-1 cursor-pointer items-center gap-3">
<input
type="checkbox"
className="h-4 w-4 shrink-0"
checked={selected}
disabled={isMovingSqlDataFile || isPreviewingSqlDataFile}
onChange={() =>
toggleSqlDataFileSelection(setSelectedFiles, file.relativePath)
}
/>
<FaFileAlt className="shrink-0 text-gray-400" />
<span className="min-w-0 flex-1 truncate">{file.name || file.fileName}</span>
</label>
<span className="hidden shrink-0 text-xs text-gray-400 dark:text-gray-500 sm:inline">
{new Date(file.createdAt).toLocaleString()}
</span>
<Button
size="xs"
variant="plain"
icon={<FaEye />}
onClick={() => handlePreviewSqlDataFile(file)}
loading={isPreviewingSqlDataFile}
disabled={isMovingSqlDataFile || isPreviewingSqlDataFile}
title={translate('::App.Platform.Preview') || 'Preview'}
/>
</div>
</li>
)
})}
</ul>
)}
</div>
</div>
)
return ( return (
<Container <Container
className="flex flex-col overflow-x-hidden overflow-y-auto lg:overflow-hidden" className="flex flex-col overflow-x-hidden overflow-y-auto lg:overflow-hidden"
@ -1206,16 +962,21 @@ GO`,
))} ))}
</select> </select>
<DbMigrateButton /> <DbMigrateButton />
<Button {/* Seed dosyalari (DbMigrator/Seeds) File Manager uzerinden yonetilir. */}
size="sm" <Link to={ROUTES_ENUM.protected.admin.files} target="_blank">
variant="default" <Button
icon={<FaFolderOpen />} size="sm"
onClick={handleOpenSqlDataFilesDialog} variant="default"
className="shadow-sm px-2 py-1" icon={<FaFolderOpen />}
title={translate('::App.SqlQueryManager.MoveFiles')} className="shadow-sm px-2 py-1"
> title={
{translate('::App.SqlQueryManager.MoveFiles')} translate('::App.SqlQueryManager.OpenFileManagerHint') ||
</Button> 'DbMigrator Seeds klasorunu Dosya Yoneticisi icinde yonet'
}
>
{translate('::App.SqlQueryManager.OpenFileManager') || 'Seed Dosyalari'}
</Button>
</Link>
</div> </div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3"> <div className="flex flex-wrap items-center gap-2 sm:gap-3">
@ -1369,83 +1130,6 @@ GO`,
</div> </div>
</div> </div>
<Dialog
isOpen={showSqlDataFilesDialog}
onClose={() => setShowSqlDataFilesDialog(false)}
onRequestClose={() => setShowSqlDataFilesDialog(false)}
width={1050}
contentClassName="max-h-[90vh] overflow-hidden"
>
<Dialog.Body className="flex max-h-[90vh] min-h-[420px] flex-col gap-2">
<div className="mb-4 shrink-0">
<div>
<h5>{translate('::App.SqlQueryManager.MoveFiles')}</h5>
</div>
</div>
{isLoadingSqlDataFiles ? (
<p className="mb-4 text-gray-600 dark:text-gray-400">{translate('::App.Loading')}</p>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-3 lg:flex-row">
{renderSqlDataPane(
sqlDataDirectoryName,
sqlDataRootFiles,
selectedRootSqlDataFiles,
setSelectedRootSqlDataFiles,
)}
<div className="flex shrink-0 flex-row items-center justify-center gap-2 lg:w-24 lg:flex-col">
<Button
size="sm"
variant="default"
icon={<FaArrowRight />}
onClick={() => handleMoveSqlDataFiles('toHostData')}
loading={isMovingSqlDataFile}
disabled={isMovingSqlDataFile || selectedRootSqlDataFiles.length === 0}
title={
translate('::App.SqlQueryManager.MoveToHostData') || 'HostData klasorune tasi'
}
/>
<Button
size="sm"
variant="default"
icon={<FaArrowLeft />}
onClick={() => handleMoveSqlDataFiles('toRoot')}
loading={isMovingSqlDataFile}
disabled={isMovingSqlDataFile || selectedHostSqlDataFiles.length === 0}
title={translate('::App.SqlQueryManager.MoveOut') || 'Disari tasi'}
/>
</div>
{renderSqlDataPane(
'HostData',
sqlDataHostFiles,
selectedHostSqlDataFiles,
setSelectedHostSqlDataFiles,
)}
</div>
)}
</Dialog.Body>
<Dialog.Footer className="flex justify-end gap-2 pt-3 mt-1">
<Button
variant="plain"
onClick={loadSqlDataFiles}
loading={isLoadingSqlDataFiles}
disabled={isLoadingSqlDataFiles || isMovingSqlDataFile}
>
{translate('::App.Platform.Refresh') || 'Yenile'}
</Button>
<Button
variant="solid"
onClick={() => setShowSqlDataFilesDialog(false)}
disabled={isMovingSqlDataFile}
>
{translate('::Close') || translate('::Cancel') || 'Kapat'}
</Button>
</Dialog.Footer>
</Dialog>
{/* Template Confirmation Dialog */} {/* Template Confirmation Dialog */}
<Dialog <Dialog
isOpen={showTemplateConfirmDialog} isOpen={showTemplateConfirmDialog}