From 8801b88a9fda9acd57e11ca480382f62033a065b Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 30 Nov 2025 01:13:59 +1100 Subject: [PATCH 01/32] fix database inheritance --- Client/Modules/FileHub/Category.razor | 2 +- Server/Persistence/ApplicationContext.cs | 11 ++++++++++- Server/Persistence/Entities/Category.cs | 6 +++++- .../EntityBuilders/CategoryEntityBuilder.cs | 4 +++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index a3c06ed..971d787 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -1,6 +1,6 @@ 

Category

-Use Syncfusion Blazor diff --git a/Server/Persistence/ApplicationContext.cs b/Server/Persistence/ApplicationContext.cs index a9cb721..26855f2 100644 --- a/Server/Persistence/ApplicationContext.cs +++ b/Server/Persistence/ApplicationContext.cs @@ -14,6 +14,15 @@ protected override void OnModelCreating(ModelBuilder builder) base.OnModelCreating(builder); builder.Entity().ToTable(ActiveDatabase.RewriteName("Company_SampleModule")); - builder.Entity().ToTable(ActiveDatabase.RewriteName("FileHub_Category")); + + builder.Entity(entity => + { + entity.ToTable(ActiveDatabase.RewriteName("FileHub_Category")); + + entity.HasOne(c => c.ParentCategory) + .WithMany(c => c.Subcategories) + .HasForeignKey(c => c.ParentId) + .OnDelete(DeleteBehavior.Restrict); + }); } } diff --git a/Server/Persistence/Entities/Category.cs b/Server/Persistence/Entities/Category.cs index e13ab13..2d16070 100644 --- a/Server/Persistence/Entities/Category.cs +++ b/Server/Persistence/Entities/Category.cs @@ -15,5 +15,9 @@ public class Category : AuditableModuleBase public int ViewOrder { get; set; } - public int ParentId { get; set; } + public int? ParentId { get; set; } + + public Category? ParentCategory { get; set; } + + public ICollection? Subcategories { get; set; } } diff --git a/Server/Persistence/Migrations/EntityBuilders/CategoryEntityBuilder.cs b/Server/Persistence/Migrations/EntityBuilders/CategoryEntityBuilder.cs index b9bb32c..502bb36 100644 --- a/Server/Persistence/Migrations/EntityBuilders/CategoryEntityBuilder.cs +++ b/Server/Persistence/Migrations/EntityBuilders/CategoryEntityBuilder.cs @@ -7,12 +7,14 @@ public class CategoryEntityBuilder : AuditableBaseEntityBuilder _primaryKey = new("PK_ICTAce_FileHub_Category", x => x.Id); private readonly ForeignKey _moduleForeignKey = new("FK_ICTAce_FileHub_Category_Module", x => x.ModuleId, "Module", "ModuleId", ReferentialAction.Cascade); + private readonly ForeignKey _parentCategoryForeignKey = new("FK_ICTAce_FileHub_Category_ParentCategory", x => x.ParentId, _entityTableName, "Id", ReferentialAction.Restrict); public CategoryEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) { EntityTableName = _entityTableName; PrimaryKey = _primaryKey; ForeignKeys.Add(_moduleForeignKey); + ForeignKeys.Add(_parentCategoryForeignKey); } protected override CategoryEntityBuilder BuildTable(ColumnsBuilder table) @@ -21,7 +23,7 @@ protected override CategoryEntityBuilder BuildTable(ColumnsBuilder table) ModuleId = AddIntegerColumn(table, "ModuleId"); Name = AddStringColumn(table, "Name", 100); ViewOrder = AddIntegerColumn(table, "ViewOrder"); - ParentId = AddIntegerColumn(table, "ParentId"); + ParentId = AddIntegerColumn(table, "ParentId", nullable: true); AddAuditableColumns(table); return this; } From 341162f01eb8753bfb376d476c39ec554f08a7dc Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 30 Nov 2025 01:18:06 +1100 Subject: [PATCH 02/32] add radzen --- Client/ICTAce.FileHub.Client.csproj | 1 + Directory.Packages.props | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Client/ICTAce.FileHub.Client.csproj b/Client/ICTAce.FileHub.Client.csproj index 2a0ee35..8e26930 100644 --- a/Client/ICTAce.FileHub.Client.csproj +++ b/Client/ICTAce.FileHub.Client.csproj @@ -14,6 +14,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 8fd6e97..7f8848f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + @@ -26,10 +27,10 @@ - + - + \ No newline at end of file From 41991d837c93240770f1ea27481e73034ceda304 Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 30 Nov 2025 16:41:28 +1100 Subject: [PATCH 03/32] abstract class ModuleService --- Client/Services/CategoryService.cs | 45 ++--------------------- Client/Services/Common/ModuleService.cs | 48 +++++++++++++++++++++++++ Client/Services/SampleModuleService.cs | 39 ++------------------ 3 files changed, 54 insertions(+), 78 deletions(-) create mode 100644 Client/Services/Common/ModuleService.cs diff --git a/Client/Services/CategoryService.cs b/Client/Services/CategoryService.cs index 67addce..fa4f149 100644 --- a/Client/Services/CategoryService.cs +++ b/Client/Services/CategoryService.cs @@ -37,56 +37,17 @@ public record CreateAndUpdateCategoryDto public int ParentId { get; set; } } -/// -/// Service interface for Category operations -/// public interface ICategoryService { Task GetAsync(int id, int moduleId); - Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10); - Task CreateAsync(int moduleId, CreateAndUpdateCategoryDto dto); - Task UpdateAsync(int id, int moduleId, CreateAndUpdateCategoryDto dto); - Task DeleteAsync(int id, int moduleId); } -/// -/// Service implementation for Category operations -/// -public class CategoryService(HttpClient http, SiteState siteState) : ServiceBase(http, siteState), ICategoryService +public class CategoryService(HttpClient http, SiteState siteState) + : ModuleService(http, siteState, "ictace/fileHub/categories"), + ICategoryService { - private string Apiurl => CreateApiUrl("ictace/fileHub/categories"); - - public Task GetAsync(int id, int moduleId) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return GetJsonAsync(url); - } - - public Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}&pageNumber={pageNumber}&pageSize={pageSize}", EntityNames.Module, moduleId); - return GetJsonAsync>(url, new PagedResult()); - } - - public Task CreateAsync(int moduleId, CreateAndUpdateCategoryDto dto) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PostJsonAsync(url, dto); - } - - public Task UpdateAsync(int id, int moduleId, CreateAndUpdateCategoryDto dto) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PutJsonAsync(url, dto); - } - - public Task DeleteAsync(int id, int moduleId) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return DeleteAsync(url); - } } diff --git a/Client/Services/Common/ModuleService.cs b/Client/Services/Common/ModuleService.cs new file mode 100644 index 0000000..6ca3271 --- /dev/null +++ b/Client/Services/Common/ModuleService.cs @@ -0,0 +1,48 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Services.Common; + +/// +/// Generic service implementation for module-scoped CRUD operations +/// +/// DTO type for get operations +/// DTO type for list operations +/// DTO type for create and update operations +public abstract class ModuleService( + HttpClient http, + SiteState siteState, + string apiPath) + : ServiceBase(http, siteState) +{ + private string Apiurl => CreateApiUrl(apiPath); + + public virtual Task GetAsync(int id, int moduleId) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return GetJsonAsync(url); + } + + public virtual Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}&pageNumber={pageNumber}&pageSize={pageSize}", EntityNames.Module, moduleId); + return GetJsonAsync>(url, new PagedResult()); + } + + public virtual Task CreateAsync(int moduleId, TCreateUpdateDto dto) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); + return PostJsonAsync(url, dto); + } + + public virtual Task UpdateAsync(int id, int moduleId, TCreateUpdateDto dto) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return PutJsonAsync(url, dto); + } + + public virtual Task DeleteAsync(int id, int moduleId) + { + var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); + return DeleteAsync(url); + } +} diff --git a/Client/Services/SampleModuleService.cs b/Client/Services/SampleModuleService.cs index a8b5f14..f41aba1 100644 --- a/Client/Services/SampleModuleService.cs +++ b/Client/Services/SampleModuleService.cs @@ -30,47 +30,14 @@ public record CreateAndUpdateSampleModuleDto public interface ISampleModuleService { Task GetAsync(int id, int moduleId); - Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10); - Task CreateAsync(int moduleId, CreateAndUpdateSampleModuleDto dto); - Task UpdateAsync(int id, int moduleId, CreateAndUpdateSampleModuleDto dto); - Task DeleteAsync(int id, int moduleId); } -public class SampleModuleService(HttpClient http, SiteState siteState) : ServiceBase(http, siteState), ISampleModuleService +public class SampleModuleService(HttpClient http, SiteState siteState) + : ModuleService(http, siteState, "company/sampleModules"), + ISampleModuleService { - private string Apiurl => CreateApiUrl("company/sampleModules"); - - public Task GetAsync(int id, int moduleId) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return GetJsonAsync(url); - } - - public Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}&pageNumber={pageNumber}&pageSize={pageSize}", EntityNames.Module, moduleId); - return GetJsonAsync>(url, new PagedResult()); - } - - public Task CreateAsync(int moduleId, CreateAndUpdateSampleModuleDto dto) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PostJsonAsync(url, dto); - } - - public Task UpdateAsync(int id, int moduleId, CreateAndUpdateSampleModuleDto dto) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return PutJsonAsync(url, dto); - } - - public Task DeleteAsync(int id, int moduleId) - { - var url = CreateAuthorizationPolicyUrl($"{Apiurl}/{id}?moduleId={moduleId}", EntityNames.Module, moduleId); - return DeleteAsync(url); - } } From 5849071024e9065f315a442e6348fd54c7cd1893 Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Sun, 30 Nov 2025 16:47:37 +1100 Subject: [PATCH 04/32] Enhance category management with tree view and context menu #5 --- Client/Modules/FileHub/Category.razor | 87 +++++++++++++++++++++++- Client/Modules/FileHub/Category.razor.cs | 17 +++++ Client/_Imports.razor | 2 + 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index a3c06ed..de9b02c 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -1,9 +1,90 @@ -

Category

+@namespace ICTAce.FileHub +@inherits ModuleBase -Use Syncfusion Blazor +@if (IsLoading) +{ +

Loading...

+} +else if (!string.IsNullOrEmpty(ErrorMessage)) +{ +
@ErrorMessage
+} +else +{ +
+ + + + + + + + + + + +
+} @code { + private RadzenContextMenu? contextMenu; + private object? SelectedCategory { get; set; } + private ListCategoryDto? currentCategory; + + private IEnumerable RootCategories => TreeData.Where(c => c.ParentId == 0); + + private void LoadChildren(TreeExpandEventArgs args) + { + var category = args.Value as ListCategoryDto; + if (category != null) + { + var children = TreeData.Where(c => c.ParentId == category.Id).ToList(); + args.Children.Data = children; + args.Children.TextProperty = "Name"; + args.Children.HasChildren = (item) => + { + var cat = item as ListCategoryDto; + return cat != null && TreeData.Any(c => c.ParentId == cat.Id); + }; + } + } + + private void OnContextMenu(MouseEventArgs args, ListCategoryDto? category) + { + if (category == null) return; + + currentCategory = category; + contextMenu?.Open(args, new ContextMenuOptions()); + } + private void OnContextMenuClick(MenuItemEventArgs args) + { + if (currentCategory == null) return; + + switch (args.Value) + { + case "add": + OnAddCategory(currentCategory); + break; + case "edit": + OnEditCategory(currentCategory); + break; + case "delete": + OnDeleteCategory(currentCategory); + break; + } + + contextMenu?.Close(); + } } diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index ab0f360..4395626 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -8,6 +8,7 @@ public partial class Category : ModuleBase private ICategoryService CategoryService { get; set; } = default!; protected PagedResult Categories { get; set; } = new(); + protected List TreeData { get; set; } = new(); protected string? ErrorMessage { get; set; } protected bool IsLoading { get; set; } @@ -18,6 +19,7 @@ protected override async Task OnInitializedAsync() try { Categories = await CategoryService.ListAsync(ModuleState.ModuleId); + TreeData = Categories.Items?.ToList() ?? new List(); } catch (Exception ex) { @@ -28,4 +30,19 @@ protected override async Task OnInitializedAsync() IsLoading = false; } } + + protected void OnAddCategory(ListCategoryDto category) + { + // Handle add subcategory + } + + protected void OnEditCategory(ListCategoryDto category) + { + // Handle edit category + } + + protected void OnDeleteCategory(ListCategoryDto category) + { + // Handle delete category + } } diff --git a/Client/_Imports.razor b/Client/_Imports.razor index 2908186..49aa3c3 100644 --- a/Client/_Imports.razor +++ b/Client/_Imports.razor @@ -22,3 +22,5 @@ @using System.Linq @using System.Net.Http @using System.Net.Http.Json +@using Radzen +@using Radzen.Blazor From a079b3c65f613b0a35f4798fef43c3c754d01d9c Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Sun, 30 Nov 2025 18:40:38 +1100 Subject: [PATCH 05/32] add radzen tree for categories #5 --- Client/Modules/FileHub/Category.razor | 73 ++---------------------- Client/Modules/FileHub/Category.razor.cs | 49 ++++++++++++---- Client/Services/CategoryService.cs | 1 + Server.Tests/Helpers/TestDbContexts.cs | 4 +- Server/Persistence/ApplicationContext.cs | 2 +- 5 files changed, 47 insertions(+), 82 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index de9b02c..3022561 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -14,77 +14,12 @@ else if (!string.IsNullOrEmpty(ErrorMessage)) else {
- - - + + (e as ListCategoryDto).Children.Any())> - - - - - -
} -@code { - private RadzenContextMenu? contextMenu; - private object? SelectedCategory { get; set; } - private ListCategoryDto? currentCategory; - - private IEnumerable RootCategories => TreeData.Where(c => c.ParentId == 0); - - private void LoadChildren(TreeExpandEventArgs args) - { - var category = args.Value as ListCategoryDto; - if (category != null) - { - var children = TreeData.Where(c => c.ParentId == category.Id).ToList(); - args.Children.Data = children; - args.Children.TextProperty = "Name"; - args.Children.HasChildren = (item) => - { - var cat = item as ListCategoryDto; - return cat != null && TreeData.Any(c => c.ParentId == cat.Id); - }; - } - } - - private void OnContextMenu(MouseEventArgs args, ListCategoryDto? category) - { - if (category == null) return; - - currentCategory = category; - contextMenu?.Open(args, new ContextMenuOptions()); - } - - private void OnContextMenuClick(MenuItemEventArgs args) - { - if (currentCategory == null) return; - - switch (args.Value) - { - case "add": - OnAddCategory(currentCategory); - break; - case "edit": - OnEditCategory(currentCategory); - break; - case "delete": - OnDeleteCategory(currentCategory); - break; - } - - contextMenu?.Close(); - } -} diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index 4395626..e57e1c5 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -4,11 +4,12 @@ namespace ICTAce.FileHub; public partial class Category : ModuleBase { + private List TreeData = new(); + [Inject] private ICategoryService CategoryService { get; set; } = default!; protected PagedResult Categories { get; set; } = new(); - protected List TreeData { get; set; } = new(); protected string? ErrorMessage { get; set; } protected bool IsLoading { get; set; } @@ -19,7 +20,7 @@ protected override async Task OnInitializedAsync() try { Categories = await CategoryService.ListAsync(ModuleState.ModuleId); - TreeData = Categories.Items?.ToList() ?? new List(); + CreateTreeStructure(); } catch (Exception ex) { @@ -31,18 +32,46 @@ protected override async Task OnInitializedAsync() } } - protected void OnAddCategory(ListCategoryDto category) + private void CreateTreeStructure() { - // Handle add subcategory - } + if (Categories.Items is null || !Categories.Items.Any()) + { + TreeData = new(); + return; + } - protected void OnEditCategory(ListCategoryDto category) - { - // Handle edit category + var categoryDict = Categories.Items.ToDictionary(c => c.Id, c => c); + + TreeData = Categories.Items + .Where(c => c.ParentId == 0 || !categoryDict.ContainsKey(c.ParentId)) + .OrderBy(c => c.ViewOrder) + .ThenBy(c => c.Name) + .ToList(); + + foreach (var category in Categories.Items) + { + if (category.ParentId != 0 && categoryDict.TryGetValue(category.ParentId, out var parent)) + { + parent.Children.Add(category); + } + } + + SortChildren(TreeData); } - protected void OnDeleteCategory(ListCategoryDto category) + private void SortChildren(List categories) { - // Handle delete category + foreach (var category in categories) + { + if (category.Children.Any()) + { + category.Children = category.Children + .OrderBy(c => c.ViewOrder) + .ThenBy(c => c.Name) + .ToList(); + + SortChildren(category.Children); + } + } } } diff --git a/Client/Services/CategoryService.cs b/Client/Services/CategoryService.cs index fa4f149..96f2b7e 100644 --- a/Client/Services/CategoryService.cs +++ b/Client/Services/CategoryService.cs @@ -22,6 +22,7 @@ public record ListCategoryDto public required string Name { get; set; } public int ViewOrder { get; set; } public int ParentId { get; set; } + public List Children { get; set; } = new(); } public record CreateAndUpdateCategoryDto diff --git a/Server.Tests/Helpers/TestDbContexts.cs b/Server.Tests/Helpers/TestDbContexts.cs index 5d30a9e..fb1e1fe 100644 --- a/Server.Tests/Helpers/TestDbContexts.cs +++ b/Server.Tests/Helpers/TestDbContexts.cs @@ -105,7 +105,7 @@ protected override void OnModelCreating(ModelBuilder builder) // Configure our entities with simple table names (no tenant rewriting for tests) builder.Entity().ToTable("Company_SampleModule"); - builder.Entity().ToTable("FileHub_Category"); + builder.Entity().ToTable("ICTAce_FileHub_Category"); // Ignore Identity entities that we don't need for these tests builder.Ignore(); @@ -181,7 +181,7 @@ protected override void OnModelCreating(ModelBuilder builder) // Configure our entities with simple table names (no tenant rewriting for tests) builder.Entity().ToTable("Company_SampleModule"); - builder.Entity().ToTable("FileHub_Category"); + builder.Entity().ToTable("ICTAce_FileHub_Category"); // Ignore Identity entities that we don't need for these tests builder.Ignore(); diff --git a/Server/Persistence/ApplicationContext.cs b/Server/Persistence/ApplicationContext.cs index 26855f2..ff4af51 100644 --- a/Server/Persistence/ApplicationContext.cs +++ b/Server/Persistence/ApplicationContext.cs @@ -17,7 +17,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity(entity => { - entity.ToTable(ActiveDatabase.RewriteName("FileHub_Category")); + entity.ToTable(ActiveDatabase.RewriteName("ICTAce_FileHub_Category")); entity.HasOne(c => c.ParentCategory) .WithMany(c => c.Subcategories) From 23dc87789af1930d414a6380e2240d59e9dc08f7 Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Tue, 2 Dec 2025 21:25:35 +1100 Subject: [PATCH 06/32] Integrate Radzen components and improve category tree #5 --- Client/Modules/FileHub/Category.razor | 4 +++- Client/Modules/FileHub/Category.razor.cs | 16 +++++++++++++++- Client/Startup/ClientStartup.cs | 4 ++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Client/Modules/FileHub/Category.razor b/Client/Modules/FileHub/Category.razor index 3022561..d23bae0 100644 --- a/Client/Modules/FileHub/Category.razor +++ b/Client/Modules/FileHub/Category.razor @@ -3,6 +3,8 @@

Categories

+ + @if (IsLoading) {

Loading...

@@ -14,7 +16,7 @@ else if (!string.IsNullOrEmpty(ErrorMessage)) else {
- + (e as ListCategoryDto).Children.Any())> diff --git a/Client/Modules/FileHub/Category.razor.cs b/Client/Modules/FileHub/Category.razor.cs index e57e1c5..d16c6fe 100644 --- a/Client/Modules/FileHub/Category.razor.cs +++ b/Client/Modules/FileHub/Category.razor.cs @@ -13,13 +13,19 @@ public partial class Category : ModuleBase protected string? ErrorMessage { get; set; } protected bool IsLoading { get; set; } + public override List Resources => new() + { + new Script($"_content/Radzen.Blazor/Radzen.Blazor.js?v={typeof(Radzen.Colors).Assembly.GetName().Version}") + }; + protected override async Task OnInitializedAsync() { IsLoading = true; ErrorMessage = null; try { - Categories = await CategoryService.ListAsync(ModuleState.ModuleId); + // Request all categories (use a large page size to get all items) + Categories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); CreateTreeStructure(); } catch (Exception ex) @@ -40,14 +46,22 @@ private void CreateTreeStructure() return; } + // Clear all children lists before rebuilding the tree + foreach (var category in Categories.Items) + { + category.Children.Clear(); + } + var categoryDict = Categories.Items.ToDictionary(c => c.Id, c => c); + // Get root items (ParentId = 0 or parent doesn't exist in the dataset) TreeData = Categories.Items .Where(c => c.ParentId == 0 || !categoryDict.ContainsKey(c.ParentId)) .OrderBy(c => c.ViewOrder) .ThenBy(c => c.Name) .ToList(); + // Build parent-child relationships foreach (var category in Categories.Items) { if (category.ParentId != 0 && categoryDict.TryGetValue(category.ParentId, out var parent)) diff --git a/Client/Startup/ClientStartup.cs b/Client/Startup/ClientStartup.cs index 1ad5337..5611d54 100644 --- a/Client/Startup/ClientStartup.cs +++ b/Client/Startup/ClientStartup.cs @@ -1,5 +1,7 @@ // Licensed to ICTAce under the MIT license. +using Radzen; + namespace ICTAce.FileHub.Client.Startup; public class ClientStartup : IClientStartup @@ -15,5 +17,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddScoped(); } + + services.AddRadzenComponents(); } } From b6af827ede6922d8da4680dc384380cabe994c70 Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Sun, 7 Dec 2025 12:40:32 +1100 Subject: [PATCH 07/32] File Entity --- Server/Persistence/ApplicationContext.cs | 6 ++++ Server/Persistence/Entities/Category.cs | 6 ---- Server/Persistence/Entities/File.cs | 24 ++++++++++++++ Server/Persistence/Entities/FileCategory.cs | 15 +++++++++ Server/Persistence/Entities/FileHub.cs | 27 ---------------- .../Persistence/Entities/FileHubCategory.cs | 20 ------------ Server/Persistence/Entities/SampleModule.cs | 3 -- .../Migrations/01000000_InitializeModule.cs | 14 +++++--- .../FileCategoryEntityBuilder.cs | 32 +++++++++++++++++++ ...bEntityBuilder.cs => FileEntityBuilder.cs} | 16 ++++++---- 10 files changed, 96 insertions(+), 67 deletions(-) create mode 100644 Server/Persistence/Entities/File.cs create mode 100644 Server/Persistence/Entities/FileCategory.cs delete mode 100644 Server/Persistence/Entities/FileHub.cs delete mode 100644 Server/Persistence/Entities/FileHubCategory.cs create mode 100644 Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs rename Server/Persistence/Migrations/EntityBuilders/{FileHubEntityBuilder.cs => FileEntityBuilder.cs} (59%) diff --git a/Server/Persistence/ApplicationContext.cs b/Server/Persistence/ApplicationContext.cs index ff4af51..61a90b0 100644 --- a/Server/Persistence/ApplicationContext.cs +++ b/Server/Persistence/ApplicationContext.cs @@ -8,6 +8,8 @@ public class ApplicationContext( { public virtual DbSet SampleModule { get; set; } public virtual DbSet Category { get; set; } + public virtual DbSet File { get; set; } + public virtual DbSet FileCategory { get; set; } protected override void OnModelCreating(ModelBuilder builder) { @@ -24,5 +26,9 @@ protected override void OnModelCreating(ModelBuilder builder) .HasForeignKey(c => c.ParentId) .OnDelete(DeleteBehavior.Restrict); }); + + builder.Entity().ToTable(ActiveDatabase.RewriteName("ICTAce_FileHub_File")); + + builder.Entity().ToTable(ActiveDatabase.RewriteName("ICTAce_FileHub_FileCategory")); } } diff --git a/Server/Persistence/Entities/Category.cs b/Server/Persistence/Entities/Category.cs index 2d16070..e245a84 100644 --- a/Server/Persistence/Entities/Category.cs +++ b/Server/Persistence/Entities/Category.cs @@ -2,12 +2,6 @@ namespace ICTAce.FileHub.Persistence.Entities; -/// -/// Represents a category that can be organized hierarchically and ordered for display purposes. -/// -/// A category may have a parent category, allowing for the creation of nested category structures. The -/// display order of categories can be controlled using the ViewOrder property. Inherits auditing properties from -/// AuditableModuleBase. public class Category : AuditableModuleBase { [MaxLength(100)] diff --git a/Server/Persistence/Entities/File.cs b/Server/Persistence/Entities/File.cs new file mode 100644 index 0000000..45429ad --- /dev/null +++ b/Server/Persistence/Entities/File.cs @@ -0,0 +1,24 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Persistence.Entities; + +public class File : AuditableModuleBase +{ + [Required] + [MaxLength(100)] + public required string Name { get; set; } + + [MaxLength(255)] + public required string FileName { get; set; } + + [MaxLength(255)] + public required string ImageName { get; set; } + + [MaxLength(1000)] + public string? Description { get; set; } + + [MaxLength(12)] + public required string FileSize { get; set; } + + public int Downloads { get; set; } +} diff --git a/Server/Persistence/Entities/FileCategory.cs b/Server/Persistence/Entities/FileCategory.cs new file mode 100644 index 0000000..956100c --- /dev/null +++ b/Server/Persistence/Entities/FileCategory.cs @@ -0,0 +1,15 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Persistence.Entities; + +public class FileCategory +{ + [Key] + public int Id { get; set; } + + public int FileHubId { get; set; } + public File FileHub { get; set; } = null!; + + public int CategoryId { get; set; } + public Category Category { get; set; } = null!; +} diff --git a/Server/Persistence/Entities/FileHub.cs b/Server/Persistence/Entities/FileHub.cs deleted file mode 100644 index 310e399..0000000 --- a/Server/Persistence/Entities/FileHub.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Licensed to ICTAce under the MIT license. - -namespace ICTAce.FileHub.Persistence.Entities; - -/// -/// Represents a file entry with associated metadata, including name, file name, description, file size, and download -/// count. -/// -/// This class is typically used to store and manage information about files within the application, such -/// as for file repositories or download modules. It includes auditing information inherited from the -/// AuditableModuleBase class. -public class FileHub : AuditableModuleBase -{ - [Required] - public required string Name { get; set; } - - [MaxLength(255)] - public required string FileName { get; set; } - - [MaxLength(1000)] - public string? Description { get; set; } - - [MaxLength(12)] - public required string FileSize { get; set; } - - public int Downloads { get; set; } -} diff --git a/Server/Persistence/Entities/FileHubCategory.cs b/Server/Persistence/Entities/FileHubCategory.cs deleted file mode 100644 index fa05c27..0000000 --- a/Server/Persistence/Entities/FileHubCategory.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Licensed to ICTAce under the MIT license. - -namespace ICTAce.FileHub.Persistence.Entities; - -/// -/// Represents the association between a file hub and a category. -/// -/// This class is typically used to model a many-to-many relationship between file hubs and categories -/// within the data model. Each instance links a specific file hub to a specific category. -public class FileHubCategory -{ - [Key] - public int Id { get; set; } - - public int FileHubId { get; set; } - public FileHub FileHub { get; set; } = null!; - - public int CategoryId { get; set; } - public Category Category { get; set; } = null!; -} diff --git a/Server/Persistence/Entities/SampleModule.cs b/Server/Persistence/Entities/SampleModule.cs index 0b8afae..e4a54e1 100644 --- a/Server/Persistence/Entities/SampleModule.cs +++ b/Server/Persistence/Entities/SampleModule.cs @@ -2,9 +2,6 @@ namespace ICTAce.FileHub.Persistence.Entities; -/// -/// Represents a module with auditable properties and a required name. -/// public class SampleModule : AuditableModuleBase { public required string Name { get; set; } diff --git a/Server/Persistence/Migrations/01000000_InitializeModule.cs b/Server/Persistence/Migrations/01000000_InitializeModule.cs index d839cc2..9096c01 100644 --- a/Server/Persistence/Migrations/01000000_InitializeModule.cs +++ b/Server/Persistence/Migrations/01000000_InitializeModule.cs @@ -15,11 +15,14 @@ protected override void Up(MigrationBuilder migrationBuilder) var sampleModuleEntityBuilder = new SampleModuleEntityBuilder(migrationBuilder, ActiveDatabase); sampleModuleEntityBuilder.Create(); - var fileHubEntityBuilder = new FileHubEntityBuilder(migrationBuilder, ActiveDatabase); - fileHubEntityBuilder.Create(); + var fileEntityBuilder = new EntityBuilders.FileEntityBuilder(migrationBuilder, ActiveDatabase); + fileEntityBuilder.Create(); var categoryEntityBuilder = new CategoryEntityBuilder(migrationBuilder, ActiveDatabase); categoryEntityBuilder.Create(); + + var fileCategoryEntityBuilder = new FileCategoryEntityBuilder(migrationBuilder, ActiveDatabase); + fileCategoryEntityBuilder.Create(); } protected override void Down(MigrationBuilder migrationBuilder) @@ -27,10 +30,13 @@ protected override void Down(MigrationBuilder migrationBuilder) var sampleModuleEntityBuilder = new SampleModuleEntityBuilder(migrationBuilder, ActiveDatabase); sampleModuleEntityBuilder.Drop(); - var fileHubEntityBuilder = new FileHubEntityBuilder(migrationBuilder, ActiveDatabase); - fileHubEntityBuilder.Drop(); + var fileEntityBuilder = new EntityBuilders.FileEntityBuilder(migrationBuilder, ActiveDatabase); + fileEntityBuilder.Drop(); var categoryEntityBuilder = new CategoryEntityBuilder(migrationBuilder, ActiveDatabase); categoryEntityBuilder.Drop(); + + var fileCategoryEntityBuilder = new FileCategoryEntityBuilder(migrationBuilder, ActiveDatabase); + fileCategoryEntityBuilder.Drop(); } } diff --git a/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs b/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs new file mode 100644 index 0000000..52144c4 --- /dev/null +++ b/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs @@ -0,0 +1,32 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Persistence.Migrations.EntityBuilders; + +public class FileCategoryEntityBuilder : AuditableBaseEntityBuilder +{ + private const string _entityTableName = "ICTAce_FileHub_FileCategory"; + private readonly PrimaryKey _primaryKey = new("PK_ICTAce_FileHub_FileCategory", x => x.Id); + private readonly ForeignKey _fileForeignKey = new("FK_ICTAce_FileHub_FileCategory_File", x => x.FileHubId, "ICTAce_FileHub_File", "Id", ReferentialAction.Cascade); + private readonly ForeignKey _categoryForeignKey = new("FK_ICTAce_FileHub_FileCategory_Category", x => x.CategoryId, "ICTAce_FileHub_Category", "Id", ReferentialAction.Cascade); + + public FileCategoryEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) + { + EntityTableName = _entityTableName; + PrimaryKey = _primaryKey; + ForeignKeys.Add(_fileForeignKey); + ForeignKeys.Add(_categoryForeignKey); + } + + protected override FileCategoryEntityBuilder BuildTable(ColumnsBuilder table) + { + Id = AddAutoIncrementColumn(table, "Id"); + FileHubId = AddIntegerColumn(table, "FileHubId"); + CategoryId = AddIntegerColumn(table, "CategoryId"); + AddAuditableColumns(table); + return this; + } + + public OperationBuilder Id { get; set; } + public OperationBuilder FileHubId { get; set; } + public OperationBuilder CategoryId { get; set; } +} \ No newline at end of file diff --git a/Server/Persistence/Migrations/EntityBuilders/FileHubEntityBuilder.cs b/Server/Persistence/Migrations/EntityBuilders/FileEntityBuilder.cs similarity index 59% rename from Server/Persistence/Migrations/EntityBuilders/FileHubEntityBuilder.cs rename to Server/Persistence/Migrations/EntityBuilders/FileEntityBuilder.cs index 6e55019..1d90dec 100644 --- a/Server/Persistence/Migrations/EntityBuilders/FileHubEntityBuilder.cs +++ b/Server/Persistence/Migrations/EntityBuilders/FileEntityBuilder.cs @@ -2,25 +2,26 @@ namespace ICTAce.FileHub.Persistence.Migrations.EntityBuilders; -public class FileHubEntityBuilder : AuditableBaseEntityBuilder +public class FileEntityBuilder : AuditableBaseEntityBuilder { - private const string _entityTableName = "ICTAce_FileHub"; - private readonly PrimaryKey _primaryKey = new("PK_ICTAce_FileHub", x => x.Id); - private readonly ForeignKey _moduleForeignKey = new("FK_ICTAce_FileHub_Module", x => x.ModuleId, "Module", "ModuleId", ReferentialAction.Cascade); + private const string _entityTableName = "ICTAce_FileHub_File"; + private readonly PrimaryKey _primaryKey = new("PK_ICTAce_FileHub_File", x => x.Id); + private readonly ForeignKey _moduleForeignKey = new("FK_ICTAce_FileHub_File_Module", x => x.ModuleId, "Module", "ModuleId", ReferentialAction.Cascade); - public FileHubEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) + public FileEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) { EntityTableName = _entityTableName; PrimaryKey = _primaryKey; ForeignKeys.Add(_moduleForeignKey); } - protected override FileHubEntityBuilder BuildTable(ColumnsBuilder table) + protected override FileEntityBuilder BuildTable(ColumnsBuilder table) { Id = AddAutoIncrementColumn(table, "Id"); ModuleId = AddIntegerColumn(table, "ModuleId"); - Name = AddMaxStringColumn(table, "Name"); + Name = AddStringColumn(table, "Name", 100); FileName = AddStringColumn(table, "FileName", 255); + FileName = AddStringColumn(table, "ImageName", 255); Description = AddStringColumn(table, "Description", 1000, nullable: true); FileSize = AddStringColumn(table, "FileSize", 12); Downloads = AddIntegerColumn(table, "Downloads"); @@ -32,6 +33,7 @@ protected override FileHubEntityBuilder BuildTable(ColumnsBuilder table) public OperationBuilder ModuleId { get; set; } public OperationBuilder Name { get; set; } public OperationBuilder FileName { get; set; } + public OperationBuilder ImageName { get; set; } public OperationBuilder Description { get; set; } public OperationBuilder FileSize { get; set; } public OperationBuilder Downloads { get; set; } From 064498565ac424d542309ea8288bcdb405b18aee Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Fri, 19 Dec 2025 07:42:08 +1100 Subject: [PATCH 08/32] Add File management CRUD API and client service --- Client/Services/FileService.cs | 71 ++++++++++ Client/Startup/ClientStartup.cs | 5 + Server/Features/Files/Controller.cs | 210 ++++++++++++++++++++++++++++ Server/Features/Files/Create.cs | 34 +++++ Server/Features/Files/Delete.cs | 17 +++ Server/Features/Files/Get.cs | 26 ++++ Server/Features/Files/List.cs | 27 ++++ Server/Features/Files/Update.cs | 32 +++++ 8 files changed, 422 insertions(+) create mode 100644 Client/Services/FileService.cs create mode 100644 Server/Features/Files/Controller.cs create mode 100644 Server/Features/Files/Create.cs create mode 100644 Server/Features/Files/Delete.cs create mode 100644 Server/Features/Files/Get.cs create mode 100644 Server/Features/Files/List.cs create mode 100644 Server/Features/Files/Update.cs diff --git a/Client/Services/FileService.cs b/Client/Services/FileService.cs new file mode 100644 index 0000000..a9613ca --- /dev/null +++ b/Client/Services/FileService.cs @@ -0,0 +1,71 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Services; + +public record GetFileDto +{ + public int Id { get; set; } + public int ModuleId { get; set; } + public required string Name { get; set; } + public required string FileName { get; set; } + public required string ImageName { get; set; } + public string? Description { get; set; } + public required string FileSize { get; set; } + public int Downloads { get; set; } + + public required string CreatedBy { get; set; } + public required DateTime CreatedOn { get; set; } + public required string ModifiedBy { get; set; } + public required DateTime ModifiedOn { get; set; } +} + +public record ListFileDto +{ + public int Id { get; set; } + public required string Name { get; set; } + public required string FileName { get; set; } + public required string ImageName { get; set; } + public string? Description { get; set; } + public required string FileSize { get; set; } + public int Downloads { get; set; } +} + +public record CreateAndUpdateFileDto +{ + [Required(ErrorMessage = "Name is required")] + [StringLength(100, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 100 characters")] + public string Name { get; set; } = string.Empty; + + [Required(ErrorMessage = "FileName is required")] + [StringLength(255, MinimumLength = 1, ErrorMessage = "FileName must be between 1 and 255 characters")] + public string FileName { get; set; } = string.Empty; + + [Required(ErrorMessage = "ImageName is required")] + [StringLength(255, MinimumLength = 1, ErrorMessage = "ImageName must be between 1 and 255 characters")] + public string ImageName { get; set; } = string.Empty; + + [StringLength(1000, ErrorMessage = "Description must not exceed 1000 characters")] + public string? Description { get; set; } + + [Required(ErrorMessage = "FileSize is required")] + [StringLength(12, MinimumLength = 1, ErrorMessage = "FileSize must be between 1 and 12 characters")] + public string FileSize { get; set; } = string.Empty; + + [Range(0, int.MaxValue, ErrorMessage = "Downloads must be greater than or equal to 0")] + public int Downloads { get; set; } +} + +public interface IFileService +{ + Task GetAsync(int id, int moduleId); + Task> ListAsync(int moduleId, int pageNumber = 1, int pageSize = 10); + Task CreateAsync(int moduleId, CreateAndUpdateFileDto dto); + Task UpdateAsync(int id, int moduleId, CreateAndUpdateFileDto dto); + Task DeleteAsync(int id, int moduleId); +} + +public class FileService(HttpClient http, SiteState siteState) + : ModuleService(http, siteState, "ictace/fileHub/files"), + IFileService +{ +} diff --git a/Client/Startup/ClientStartup.cs b/Client/Startup/ClientStartup.cs index 5611d54..fe4fa25 100644 --- a/Client/Startup/ClientStartup.cs +++ b/Client/Startup/ClientStartup.cs @@ -18,6 +18,11 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); } + if (!services.Any(s => s.ServiceType == typeof(Services.IFileService))) + { + services.AddScoped(); + } + services.AddRadzenComponents(); } } diff --git a/Server/Features/Files/Controller.cs b/Server/Features/Files/Controller.cs new file mode 100644 index 0000000..b9a6875 --- /dev/null +++ b/Server/Features/Files/Controller.cs @@ -0,0 +1,210 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +[Route("api/ictace/fileHub/files")] +[ApiController] +public class ICTAceFileHubFilesController( + IMediator mediator, + ILogManager logger, + IHttpContextAccessor accessor) + : ModuleControllerBase(logger, accessor) +{ + private readonly IMediator _mediator = mediator; + + [HttpGet("{id}")] + [Authorize(Policy = PolicyNames.ViewModule)] + [ProducesResponseType(typeof(GetFileDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetAsync( + int id, + [FromQuery] int moduleId, + CancellationToken cancellationToken = default) + { + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized FileHub File Get Attempt Id={Id} in ModuleId={ModuleId}", id, moduleId); + return Forbid(); + } + + if (id <= 0) + { + return BadRequest("Invalid File ID"); + } + + var query = new GetFileRequest + { + ModuleId = moduleId, + Id = id, + }; + + var file = await _mediator.Send(query, cancellationToken).ConfigureAwait(false); + + if (file is null) + { + _logger.Log(LogLevel.Warning, this, LogFunction.Read, + "File Not Found Id={Id} in ModuleId={ModuleId}", id, moduleId); + return NotFound(); + } + + return Ok(file); + } + + [HttpGet("")] + [Authorize(Policy = PolicyNames.ViewModule)] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> ListAsync( + [FromQuery] int moduleId, + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 10, + CancellationToken cancellationToken = default) + { + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized File List Attempt ModuleId={ModuleId}", moduleId); + return Forbid(); + } + + if (pageSize > 100) + { + pageSize = 100; + } + + if (pageNumber < 1) + { + pageNumber = 1; + } + + var query = new ListFileRequest + { + ModuleId = moduleId, + PageNumber = pageNumber, + PageSize = pageSize, + }; + + var result = await _mediator.Send(query, cancellationToken).ConfigureAwait(false); + + if (result is null) + { + return NotFound(); + } + + return Ok(result); + } + + [HttpPost("")] + [Authorize(Policy = PolicyNames.EditModule)] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateAsync( + [FromQuery] int moduleId, + [FromBody] CreateAndUpdateFileDto dto, + CancellationToken cancellationToken = default) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized File Create Attempt ModuleId={ModuleId}", moduleId); + return Forbid(); + } + + var command = new CreateFileRequest + { + ModuleId = moduleId, + Name = dto.Name, + FileName = dto.FileName, + ImageName = dto.ImageName, + Description = dto.Description, + FileSize = dto.FileSize, + Downloads = dto.Downloads, + }; + + var id = await _mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return Created( + Url.Action(nameof(GetAsync), new { id, moduleId = command.ModuleId }) ?? string.Empty, + id); + } + + [HttpPut("{id}")] + [Authorize(Policy = PolicyNames.EditModule)] + [ProducesResponseType(typeof(int), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> UpdateAsync( + int id, + [FromQuery] int moduleId, + [FromBody] CreateAndUpdateFileDto dto, + CancellationToken cancellationToken = default) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized File Update Attempt Id={Id} in ModuleId={ModuleId}", id, moduleId); + return Forbid(); + } + + var command = new UpdateFileRequest + { + Id = id, + ModuleId = moduleId, + Name = dto.Name, + FileName = dto.FileName, + ImageName = dto.ImageName, + Description = dto.Description, + FileSize = dto.FileSize, + Downloads = dto.Downloads, + }; + + var result = await _mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return Ok(result); + } + + [HttpDelete("{id}")] + [Authorize(Policy = PolicyNames.EditModule)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeleteAsync( + int id, + [FromQuery] int moduleId, + CancellationToken cancellationToken = default) + { + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized File Delete Attempt Id={Id} in ModuleId={ModuleId}", id, moduleId); + return Forbid(); + } + + if (id <= 0) + { + return BadRequest("Invalid File ID"); + } + + var command = new DeleteFileRequest + { + ModuleId = moduleId, + Id = id, + }; + + await _mediator.Send(command, cancellationToken).ConfigureAwait(false); + + return NoContent(); + } +} diff --git a/Server/Features/Files/Create.cs b/Server/Features/Files/Create.cs new file mode 100644 index 0000000..c7871d0 --- /dev/null +++ b/Server/Features/Files/Create.cs @@ -0,0 +1,34 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +public record CreateFileRequest : RequestBase, IRequest +{ + public string Name { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; + public string ImageName { get; set; } = string.Empty; + public string? Description { get; set; } + public string FileSize { get; set; } = string.Empty; + public int Downloads { get; set; } +} + +public class CreateHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler +{ + private static readonly CreateMapper _mapper = new(); + + public Task Handle(CreateFileRequest request, CancellationToken cancellationToken) + { + return HandleCreateAsync( + request: request, + mapToEntity: _mapper.ToEntity, + cancellationToken: cancellationToken + ); + } +} + +[Mapper] +internal sealed partial class CreateMapper +{ + internal partial ICTAce.FileHub.Persistence.Entities.File ToEntity(CreateFileRequest request); +} diff --git a/Server/Features/Files/Delete.cs b/Server/Features/Files/Delete.cs new file mode 100644 index 0000000..458b0b3 --- /dev/null +++ b/Server/Features/Files/Delete.cs @@ -0,0 +1,17 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +public record DeleteFileRequest : EntityRequestBase, IRequest; + +public class DeleteHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler +{ + public Task Handle(DeleteFileRequest request, CancellationToken cancellationToken) + { + return HandleDeleteAsync( + request: request, + cancellationToken: cancellationToken + ); + } +} diff --git a/Server/Features/Files/Get.cs b/Server/Features/Files/Get.cs new file mode 100644 index 0000000..a5df3f8 --- /dev/null +++ b/Server/Features/Files/Get.cs @@ -0,0 +1,26 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +public record GetFileRequest : EntityRequestBase, IRequest; + +public class GetHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler +{ + private static readonly GetMapper _mapper = new(); + + public Task Handle(GetFileRequest request, CancellationToken cancellationToken) + { + return HandleGetAsync( + request: request, + mapToResponse: _mapper.ToGetResponse, + cancellationToken: cancellationToken + ); + } +} + +[Mapper] +internal sealed partial class GetMapper +{ + public partial GetFileDto ToGetResponse(ICTAce.FileHub.Persistence.Entities.File file); +} diff --git a/Server/Features/Files/List.cs b/Server/Features/Files/List.cs new file mode 100644 index 0000000..40310e5 --- /dev/null +++ b/Server/Features/Files/List.cs @@ -0,0 +1,27 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +public record ListFileRequest : PagedRequestBase, IRequest>; + +public class ListHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler?> +{ + private static readonly ListMapper _mapper = new(); + + public Task?> Handle(ListFileRequest request, CancellationToken cancellationToken) + { + return HandleListAsync( + request: request, + mapToResponse: _mapper.ToListResponse, + orderBy: query => query.OrderBy(f => f.Name), + cancellationToken: cancellationToken + ); + } +} + +[Mapper] +internal sealed partial class ListMapper +{ + public partial ListFileDto ToListResponse(ICTAce.FileHub.Persistence.Entities.File file); +} diff --git a/Server/Features/Files/Update.cs b/Server/Features/Files/Update.cs new file mode 100644 index 0000000..6a10122 --- /dev/null +++ b/Server/Features/Files/Update.cs @@ -0,0 +1,32 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +public record UpdateFileRequest : EntityRequestBase, IRequest +{ + public required string Name { get; set; } + public required string FileName { get; set; } + public required string ImageName { get; set; } + public string? Description { get; set; } + public required string FileSize { get; set; } + public int Downloads { get; set; } +} + +public class UpdateHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler +{ + public Task Handle(UpdateFileRequest request, CancellationToken cancellationToken) + { + return HandleUpdateAsync( + request: request, + setPropertyCalls: setter => setter + .SetProperty(e => e.Name, request.Name) + .SetProperty(e => e.FileName, request.FileName) + .SetProperty(e => e.ImageName, request.ImageName) + .SetProperty(e => e.Description, request.Description) + .SetProperty(e => e.FileSize, request.FileSize) + .SetProperty(e => e.Downloads, request.Downloads), + cancellationToken: cancellationToken + ); + } +} From 8fafbb9a3d39550dd56fa76472b0dc191f3b72a3 Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Fri, 19 Dec 2025 21:56:35 +1100 Subject: [PATCH 09/32] Enhance file edit UI, add metadata & category selection #18 --- Client/Modules/FileHub/Edit.razor | 120 ++++++++++++++++- Client/Modules/FileHub/Edit.razor.cs | 187 ++++++++++++++++++++++++--- 2 files changed, 281 insertions(+), 26 deletions(-) diff --git a/Client/Modules/FileHub/Edit.razor b/Client/Modules/FileHub/Edit.razor index df9b6f2..caee12b 100644 --- a/Client/Modules/FileHub/Edit.razor +++ b/Client/Modules/FileHub/Edit.razor @@ -1,20 +1,128 @@ @namespace ICTAce.FileHub @inherits ModuleBase + +
-
- +
+
+

@(PageState.Action == "Add" ? "Add New File" : "Edit File")

+
+
+ +
+ +
+ +
Name is required (max 100 characters)
+
+
+ +
+ +
+ +
File name is required (max 255 characters)
+
+
+ +
+ +
+ +
Image name is required (max 255 characters)
+
+
+ +
+
- + +
File size is required (max 12 characters)
+
+
+ +
+ +
+ +
Optional - max 1000 characters
+
+
+ +
+ +
+ +
Downloads must be 0 or greater
+
+
+ +
+ +
+ @if (_isLoadingCategories) + { +

Loading categories...

+ } + else + { +
+ + + + + +
+
+ @if (_selectedCategories != null && _selectedCategories.Any()) + { + Selected: @string.Join(", ", _selectedCategories.OfType().Where(c => c.Id > 0).Select(c => c.Name)) + } + else + { + No categories selected + } +
+ }
- - @Localizer["Cancel"] + +
+ + + @Localizer["Cancel"] + +
+

@if (PageState.Action == "Edit") { - + } diff --git a/Client/Modules/FileHub/Edit.razor.cs b/Client/Modules/FileHub/Edit.razor.cs index 5dfd8bc..6b12c98 100644 --- a/Client/Modules/FileHub/Edit.razor.cs +++ b/Client/Modules/FileHub/Edit.razor.cs @@ -1,10 +1,13 @@ // Licensed to ICTAce under the MIT license. +using Radzen; + namespace ICTAce.FileHub; public partial class Edit { - [Inject] protected ISampleModuleService FileHubService { get; set; } = default!; + [Inject] protected Services.IFileService FileService { get; set; } = default!; + [Inject] protected ICategoryService CategoryService { get; set; } = default!; [Inject] protected NavigationManager NavigationManager { get; set; } = default!; [Inject] protected IStringLocalizer Localizer { get; set; } = default!; @@ -12,11 +15,12 @@ public partial class Edit public override string Actions => "Add,Edit"; - public override string Title => "Manage FileHub"; + public override string Title => "Manage File"; public override List Resources => [ - new Stylesheet(ModulePath() + "Module.css") + new Stylesheet(ModulePath() + "Module.css"), + new Script("_content/Radzen.Blazor/Radzen.Blazor.js") ]; private ElementReference form; @@ -24,36 +28,147 @@ public partial class Edit private int _id; private string _name = string.Empty; + private string _fileName = string.Empty; + private string _imageName = string.Empty; + private string? _description; + private string _fileSize = string.Empty; + private int _downloads; + private string _createdby = string.Empty; private DateTime _createdon; private string _modifiedby = string.Empty; private DateTime _modifiedon; + private List _treeData = []; + private ListCategoryDto _rootNode = new() { Name = "Categories" }; + private IEnumerable? _selectedCategories; + private bool _isLoadingCategories; + protected override async Task OnInitializedAsync() { try { + // Load categories + await LoadCategories(); + if (string.Equals(PageState.Action, "Edit", StringComparison.Ordinal)) { _id = Int32.Parse(PageState.QueryString["id"], System.Globalization.CultureInfo.InvariantCulture); - var filehub = await FileHubService.GetAsync(_id, ModuleState.ModuleId).ConfigureAwait(true); - if (filehub != null) + var file = await FileService.GetAsync(_id, ModuleState.ModuleId).ConfigureAwait(true); + if (file != null) { - _name = filehub.Name; - _createdby = filehub.CreatedBy; - _createdon = filehub.CreatedOn; - _modifiedby = filehub.ModifiedBy; - _modifiedon = filehub.ModifiedOn; + _name = file.Name; + _fileName = file.FileName; + _imageName = file.ImageName; + _description = file.Description; + _fileSize = file.FileSize; + _downloads = file.Downloads; + _createdby = file.CreatedBy; + _createdon = file.CreatedOn; + _modifiedby = file.ModifiedBy; + _modifiedon = file.ModifiedOn; } } } catch (Exception ex) { - await logger.LogError(ex, "Error Loading FileHub {Id} {Error}", _id, ex.Message).ConfigureAwait(true); + await logger.LogError(ex, "Error Loading File {Id} {Error}", _id, ex.Message).ConfigureAwait(true); AddModuleMessage(Localizer["Message.LoadError"], MessageType.Error); } } + private async Task LoadCategories() + { + try + { + _isLoadingCategories = true; + var categories = await CategoryService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue); + CreateTreeStructure(categories); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Loading Categories {Error}", ex.Message).ConfigureAwait(true); + AddModuleMessage("Failed to load categories", MessageType.Warning); + } + finally + { + _isLoadingCategories = false; + } + } + + private void CreateTreeStructure(PagedResult categories) + { + if (categories.Items is null || !categories.Items.Any()) + { + _treeData = []; + _rootNode = new ListCategoryDto + { + Id = 0, + Name = "Categories", + ParentId = -1, + ViewOrder = 0, + IsExpanded = true, + Children = [] + }; + return; + } + + foreach (var category in categories.Items) + { + category.Children.Clear(); + } + + var categoryDict = categories.Items.ToDictionary(c => c.Id, c => c); + + _treeData = categories.Items + .Where(c => c.ParentId is null || !categoryDict.ContainsKey(c.ParentId.Value)) + .OrderBy(c => c.ViewOrder) + .ThenBy(c => c.Name, StringComparer.Ordinal) + .ToList(); + + foreach (var category in categories.Items) + { + if (category.ParentId is not null && categoryDict.TryGetValue(category.ParentId.Value, out var parent)) + { + parent.Children.Add(category); + } + } + + SortChildren(_treeData); + + _rootNode = new ListCategoryDto + { + Id = 0, + Name = "Categories", + ParentId = null, + ViewOrder = 0, + IsExpanded = true, + Children = _treeData + }; + } + + private static void SortChildren(List categories) + { + foreach (var category in categories) + { + if (category.Children.Any()) + { + category.Children = category.Children + .OrderBy(c => c.ViewOrder) + .ThenBy(c => c.Name, StringComparer.Ordinal) + .ToList(); + + SortChildren(category.Children.ToList()); + } + } + } + + private void OnCategorySelectionChanged(TreeEventArgs args) + { + _selectedCategories = args.Value as IEnumerable; + StateHasChanged(); + } + private async Task Save() { try @@ -64,21 +179,31 @@ private async Task Save() { if (string.Equals(PageState.Action, "Add", StringComparison.Ordinal)) { - var dto = new CreateAndUpdateSampleModuleDto + var dto = new CreateAndUpdateFileDto { - Name = _name + Name = _name, + FileName = _fileName, + ImageName = _imageName, + Description = _description, + FileSize = _fileSize, + Downloads = _downloads }; - var id = await FileHubService.CreateAsync(ModuleState.ModuleId, dto).ConfigureAwait(true); - await logger.LogInformation("FileHub Created {Id}", id).ConfigureAwait(true); + var id = await FileService.CreateAsync(ModuleState.ModuleId, dto).ConfigureAwait(true); + await logger.LogInformation("File Created {Id}", id).ConfigureAwait(true); } else { - var dto = new CreateAndUpdateSampleModuleDto + var dto = new CreateAndUpdateFileDto { - Name = _name + Name = _name, + FileName = _fileName, + ImageName = _imageName, + Description = _description, + FileSize = _fileSize, + Downloads = _downloads }; - var id = await FileHubService.UpdateAsync(_id, ModuleState.ModuleId, dto).ConfigureAwait(true); - await logger.LogInformation("FileHub Updated {Id}", id).ConfigureAwait(true); + var id = await FileService.UpdateAsync(_id, ModuleState.ModuleId, dto).ConfigureAwait(true); + await logger.LogInformation("File Updated {Id}", id).ConfigureAwait(true); } NavigationManager.NavigateTo(NavigateUrl()); } @@ -89,8 +214,30 @@ private async Task Save() } catch (Exception ex) { - await logger.LogError(ex, "Error Saving FileHub {Error}", ex.Message).ConfigureAwait(true); + await logger.LogError(ex, "Error Saving File {Error}", ex.Message).ConfigureAwait(true); AddModuleMessage(Localizer["Message.SaveError"], MessageType.Error); } } + + private IEnumerable GetAllCategories() + { + var result = new List(); + AddCategoriesRecursive(_treeData, result); + return result; + } + + private static void AddCategoriesRecursive(IEnumerable categories, List result) + { + foreach (var category in categories) + { + if (category.Id > 0) + { + result.Add(category); + } + if (category.Children.Any()) + { + AddCategoriesRecursive(category.Children, result); + } + } + } } From 61dd9196bde8d9be5f1ce9f7ac91611ee43727d1 Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Mon, 22 Dec 2025 18:31:15 +1100 Subject: [PATCH 10/32] Update FileCategory FKs and fix ImageName column mapping --- Server/Persistence/ApplicationContext.cs | 15 ++++++++++++++- .../EntityBuilders/FileCategoryEntityBuilder.cs | 4 ++-- .../EntityBuilders/FileEntityBuilder.cs | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Server/Persistence/ApplicationContext.cs b/Server/Persistence/ApplicationContext.cs index 61a90b0..c2f56da 100644 --- a/Server/Persistence/ApplicationContext.cs +++ b/Server/Persistence/ApplicationContext.cs @@ -29,6 +29,19 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity().ToTable(ActiveDatabase.RewriteName("ICTAce_FileHub_File")); - builder.Entity().ToTable(ActiveDatabase.RewriteName("ICTAce_FileHub_FileCategory")); + builder.Entity(entity => + { + entity.ToTable(ActiveDatabase.RewriteName("ICTAce_FileHub_FileCategory")); + + entity.HasOne(fc => fc.FileHub) + .WithMany() + .HasForeignKey(fc => fc.FileHubId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(fc => fc.Category) + .WithMany() + .HasForeignKey(fc => fc.CategoryId) + .OnDelete(DeleteBehavior.Restrict); + }); } } diff --git a/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs b/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs index 52144c4..f48c7a7 100644 --- a/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs +++ b/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs @@ -7,7 +7,7 @@ public class FileCategoryEntityBuilder : AuditableBaseEntityBuilder _primaryKey = new("PK_ICTAce_FileHub_FileCategory", x => x.Id); private readonly ForeignKey _fileForeignKey = new("FK_ICTAce_FileHub_FileCategory_File", x => x.FileHubId, "ICTAce_FileHub_File", "Id", ReferentialAction.Cascade); - private readonly ForeignKey _categoryForeignKey = new("FK_ICTAce_FileHub_FileCategory_Category", x => x.CategoryId, "ICTAce_FileHub_Category", "Id", ReferentialAction.Cascade); + private readonly ForeignKey _categoryForeignKey = new("FK_ICTAce_FileHub_FileCategory_Category", x => x.CategoryId, "ICTAce_FileHub_Category", "Id", ReferentialAction.Restrict); public FileCategoryEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) { @@ -29,4 +29,4 @@ protected override FileCategoryEntityBuilder BuildTable(ColumnsBuilder table) public OperationBuilder Id { get; set; } public OperationBuilder FileHubId { get; set; } public OperationBuilder CategoryId { get; set; } -} \ No newline at end of file +} diff --git a/Server/Persistence/Migrations/EntityBuilders/FileEntityBuilder.cs b/Server/Persistence/Migrations/EntityBuilders/FileEntityBuilder.cs index 1d90dec..52fb9bc 100644 --- a/Server/Persistence/Migrations/EntityBuilders/FileEntityBuilder.cs +++ b/Server/Persistence/Migrations/EntityBuilders/FileEntityBuilder.cs @@ -21,7 +21,7 @@ protected override FileEntityBuilder BuildTable(ColumnsBuilder table) ModuleId = AddIntegerColumn(table, "ModuleId"); Name = AddStringColumn(table, "Name", 100); FileName = AddStringColumn(table, "FileName", 255); - FileName = AddStringColumn(table, "ImageName", 255); + ImageName = AddStringColumn(table, "ImageName", 255); Description = AddStringColumn(table, "Description", 1000, nullable: true); FileSize = AddStringColumn(table, "FileSize", 12); Downloads = AddIntegerColumn(table, "Downloads"); From 90a9498db3f825f45af92425a39c1cc00c520c78 Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Mon, 22 Dec 2025 20:45:11 +1100 Subject: [PATCH 11/32] Add file upload support to file edit form and backend #18 --- Client/GlobalUsings.cs | 1 + Client/Modules/FileHub/Edit.razor | 22 +++++++- Client/Modules/FileHub/Edit.razor.cs | 81 +++++++++++++++++++++++++++- Client/Services/FileService.cs | 20 +++++++ Server/Features/Files/Controller.cs | 77 +++++++++++++++++++++++++- 5 files changed, 197 insertions(+), 4 deletions(-) diff --git a/Client/GlobalUsings.cs b/Client/GlobalUsings.cs index fba3ce1..f691c5e 100644 --- a/Client/GlobalUsings.cs +++ b/Client/GlobalUsings.cs @@ -4,6 +4,7 @@ global using ICTAce.FileHub.Services; global using ICTAce.FileHub.Services.Common; global using Microsoft.AspNetCore.Components; +global using Microsoft.AspNetCore.Components.Forms; global using Microsoft.AspNetCore.Components.Web; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Localization; diff --git a/Client/Modules/FileHub/Edit.razor b/Client/Modules/FileHub/Edit.razor index caee12b..cd78d69 100644 --- a/Client/Modules/FileHub/Edit.razor +++ b/Client/Modules/FileHub/Edit.razor @@ -11,6 +11,26 @@ +
+ +
+ + @if (!string.IsNullOrEmpty(_uploadedFileName)) + { +
+ Uploaded: @_uploadedFileName +
+ } + @if (_isUploading) + { +
+ + Uploading... +
+ } +
+
+
@@ -112,7 +132,7 @@
- diff --git a/Client/Modules/FileHub/Edit.razor.cs b/Client/Modules/FileHub/Edit.razor.cs index 6b12c98..a098e88 100644 --- a/Client/Modules/FileHub/Edit.razor.cs +++ b/Client/Modules/FileHub/Edit.razor.cs @@ -1,5 +1,6 @@ // Licensed to ICTAce under the MIT license. +using Microsoft.AspNetCore.Components.Forms; using Radzen; namespace ICTAce.FileHub; @@ -25,6 +26,8 @@ public partial class Edit private ElementReference form; private bool _validated; + private bool _isUploading; + private string? _uploadedFileName; private int _id; private string _name = string.Empty; @@ -67,6 +70,13 @@ protected override async Task OnInitializedAsync() _createdon = file.CreatedOn; _modifiedby = file.ModifiedBy; _modifiedon = file.ModifiedOn; + + // Load selected categories + if (file.CategoryIds.Any()) + { + var selectedCats = GetAllCategories().Where(c => file.CategoryIds.Contains(c.Id)).ToList(); + _selectedCategories = selectedCats.Cast(); + } } } } @@ -77,6 +87,65 @@ protected override async Task OnInitializedAsync() } } + private async Task OnFileSelected(InputFileChangeEventArgs e) + { + try + { + _isUploading = true; + StateHasChanged(); + + var file = e.File; + + // Limit file size to 100MB + const long maxFileSize = 100 * 1024 * 1024; + if (file.Size > maxFileSize) + { + AddModuleMessage("File size exceeds 100MB limit", MessageType.Error); + return; + } + + // Upload the file + using var stream = file.OpenReadStream(maxFileSize); + _uploadedFileName = await FileService.UploadFileAsync(ModuleState.ModuleId, stream, file.Name).ConfigureAwait(true); + + // Auto-fill form fields + if (string.IsNullOrEmpty(_name)) + { + _name = Path.GetFileNameWithoutExtension(file.Name); + } + if (string.IsNullOrEmpty(_fileName)) + { + _fileName = _uploadedFileName; + } + _fileSize = FormatFileSize(file.Size); + + AddModuleMessage("File uploaded successfully", MessageType.Success); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Uploading File {Error}", ex.Message).ConfigureAwait(true); + AddModuleMessage("Error uploading file", MessageType.Error); + } + finally + { + _isUploading = false; + StateHasChanged(); + } + } + + private static string FormatFileSize(long bytes) + { + string[] sizes = ["B", "KB", "MB", "GB", "TB"]; + double len = bytes; + int order = 0; + while (len >= 1024 && order < sizes.Length - 1) + { + order++; + len = len / 1024; + } + return $"{len:0.##} {sizes[order]}"; + } + private async Task LoadCategories() { try @@ -177,6 +246,12 @@ private async Task Save() var interop = new Oqtane.UI.Interop(JSRuntime); if (await interop.FormValid(form)) { + var selectedCategoryIds = _selectedCategories? + .OfType() + .Where(c => c.Id > 0) + .Select(c => c.Id) + .ToList() ?? []; + if (string.Equals(PageState.Action, "Add", StringComparison.Ordinal)) { var dto = new CreateAndUpdateFileDto @@ -186,7 +261,8 @@ private async Task Save() ImageName = _imageName, Description = _description, FileSize = _fileSize, - Downloads = _downloads + Downloads = _downloads, + CategoryIds = selectedCategoryIds }; var id = await FileService.CreateAsync(ModuleState.ModuleId, dto).ConfigureAwait(true); await logger.LogInformation("File Created {Id}", id).ConfigureAwait(true); @@ -200,7 +276,8 @@ private async Task Save() ImageName = _imageName, Description = _description, FileSize = _fileSize, - Downloads = _downloads + Downloads = _downloads, + CategoryIds = selectedCategoryIds }; var id = await FileService.UpdateAsync(_id, ModuleState.ModuleId, dto).ConfigureAwait(true); await logger.LogInformation("File Updated {Id}", id).ConfigureAwait(true); diff --git a/Client/Services/FileService.cs b/Client/Services/FileService.cs index a9613ca..69ac83a 100644 --- a/Client/Services/FileService.cs +++ b/Client/Services/FileService.cs @@ -12,6 +12,7 @@ public record GetFileDto public string? Description { get; set; } public required string FileSize { get; set; } public int Downloads { get; set; } + public List CategoryIds { get; set; } = []; public required string CreatedBy { get; set; } public required DateTime CreatedOn { get; set; } @@ -53,6 +54,8 @@ public record CreateAndUpdateFileDto [Range(0, int.MaxValue, ErrorMessage = "Downloads must be greater than or equal to 0")] public int Downloads { get; set; } + + public List CategoryIds { get; set; } = []; } public interface IFileService @@ -62,10 +65,27 @@ public interface IFileService Task CreateAsync(int moduleId, CreateAndUpdateFileDto dto); Task UpdateAsync(int id, int moduleId, CreateAndUpdateFileDto dto); Task DeleteAsync(int id, int moduleId); + Task UploadFileAsync(int moduleId, Stream fileStream, string fileName); } public class FileService(HttpClient http, SiteState siteState) : ModuleService(http, siteState, "ictace/fileHub/files"), IFileService { + private readonly HttpClient _http = http; + + public async Task UploadFileAsync(int moduleId, Stream fileStream, string fileName) + { + var url = CreateAuthorizationPolicyUrl($"{CreateApiUrl("ictace/fileHub/files")}/upload?moduleId={moduleId}", EntityNames.Module, moduleId); + + using var content = new MultipartFormDataContent(); + using var streamContent = new StreamContent(fileStream); + streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + content.Add(streamContent, "file", fileName); + + var response = await _http.PostAsync(url, content).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } } diff --git a/Server/Features/Files/Controller.cs b/Server/Features/Files/Controller.cs index b9a6875..77d937c 100644 --- a/Server/Features/Files/Controller.cs +++ b/Server/Features/Files/Controller.cs @@ -7,10 +7,14 @@ namespace ICTAce.FileHub.Features.Files; public class ICTAceFileHubFilesController( IMediator mediator, ILogManager logger, - IHttpContextAccessor accessor) + IHttpContextAccessor accessor, + IWebHostEnvironment environment, + ITenantManager tenantManager) : ModuleControllerBase(logger, accessor) { private readonly IMediator _mediator = mediator; + private readonly IWebHostEnvironment _environment = environment; + private readonly ITenantManager _tenantManager = tenantManager; [HttpGet("{id}")] [Authorize(Policy = PolicyNames.ViewModule)] @@ -207,4 +211,75 @@ public async Task DeleteAsync( return NoContent(); } + + [HttpPost("upload")] + [Authorize(Policy = PolicyNames.EditModule)] + [ProducesResponseType(typeof(string), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> UploadFileAsync( + [FromQuery] int moduleId, + IFormFile file, + CancellationToken cancellationToken = default) + { + if (!IsAuthorizedEntityId(EntityNames.Module, moduleId)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, + "Unauthorized File Upload Attempt ModuleId={ModuleId}", moduleId); + return Forbid(); + } + + if (file is null || file.Length == 0) + { + return BadRequest("No file uploaded"); + } + + try + { + var alias = _tenantManager.GetAlias(); + var filePath = GetFileStoragePath(alias.TenantId, alias.SiteId, moduleId); + + // Ensure directory exists + if (!Directory.Exists(filePath)) + { + Directory.CreateDirectory(filePath); + } + + // Generate unique filename to prevent overwrites + var fileName = $"{Guid.NewGuid()}_{Path.GetFileName(file.FileName)}"; + var fullPath = Path.Combine(filePath, fileName); + + // Save the file + using (var stream = new FileStream(fullPath, FileMode.Create)) + { + await file.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); + } + + _logger.Log(LogLevel.Information, this, LogFunction.Create, + "File Uploaded FileName={FileName} Size={Size} ModuleId={ModuleId}", + fileName, file.Length, moduleId); + + return Ok(fileName); + } + catch (Exception ex) + { + _logger.Log(LogLevel.Error, this, LogFunction.Create, + ex, "Error Uploading File ModuleId={ModuleId}", moduleId); + return StatusCode(StatusCodes.Status500InternalServerError, "Error uploading file"); + } + } + + private string GetFileStoragePath(int tenantId, int siteId, int moduleId) + { + // Content/Tenants/{TenantId}/Sites/{SiteId}/FileHub/{ModuleId}/ + return Path.Combine( + _environment.ContentRootPath, + "Content", + "Tenants", + tenantId.ToString(System.Globalization.CultureInfo.InvariantCulture), + "Sites", + siteId.ToString(System.Globalization.CultureInfo.InvariantCulture), + "FileHub", + moduleId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } } From cb64fe8e86847c11c4036ad85aa15a622bf687cf Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Mon, 22 Dec 2025 23:50:34 +1100 Subject: [PATCH 12/32] Switch to RadzenUpload for file uploads with progress #18 --- Client/Modules/FileHub/Edit.razor | 14 +++++- Client/Modules/FileHub/Edit.razor.cs | 75 +++++++++++++++++++--------- 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/Client/Modules/FileHub/Edit.razor b/Client/Modules/FileHub/Edit.razor index cd78d69..0be6980 100644 --- a/Client/Modules/FileHub/Edit.razor +++ b/Client/Modules/FileHub/Edit.razor @@ -14,7 +14,17 @@
- + + @if (!string.IsNullOrEmpty(_uploadedFileName)) {
@@ -25,7 +35,7 @@ {
- Uploading... + Uploading... @_uploadProgress%
}
diff --git a/Client/Modules/FileHub/Edit.razor.cs b/Client/Modules/FileHub/Edit.razor.cs index a098e88..e787ea6 100644 --- a/Client/Modules/FileHub/Edit.razor.cs +++ b/Client/Modules/FileHub/Edit.razor.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Components.Forms; using Radzen; +using Radzen.Blazor; namespace ICTAce.FileHub; @@ -27,6 +28,7 @@ public partial class Edit private ElementReference form; private bool _validated; private bool _isUploading; + private int _uploadProgress; private string? _uploadedFileName; private int _id; @@ -87,39 +89,43 @@ protected override async Task OnInitializedAsync() } } - private async Task OnFileSelected(InputFileChangeEventArgs e) + private async Task OnFileSelected(UploadChangeEventArgs args) { try { _isUploading = true; + _uploadProgress = 0; StateHasChanged(); - var file = e.File; - - // Limit file size to 100MB - const long maxFileSize = 100 * 1024 * 1024; - if (file.Size > maxFileSize) + foreach (var file in args.Files) { - AddModuleMessage("File size exceeds 100MB limit", MessageType.Error); - return; - } + // Limit file size to 100MB + const long maxFileSize = 100 * 1024 * 1024; + if (file.Size > maxFileSize) + { + AddModuleMessage("File size exceeds 100MB limit", MessageType.Error); + _isUploading = false; + return; + } - // Upload the file - using var stream = file.OpenReadStream(maxFileSize); - _uploadedFileName = await FileService.UploadFileAsync(ModuleState.ModuleId, stream, file.Name).ConfigureAwait(true); - - // Auto-fill form fields - if (string.IsNullOrEmpty(_name)) - { - _name = Path.GetFileNameWithoutExtension(file.Name); - } - if (string.IsNullOrEmpty(_fileName)) - { - _fileName = _uploadedFileName; + // Upload the file + using var stream = file.OpenReadStream(maxFileSize); + _uploadedFileName = await FileService.UploadFileAsync(ModuleState.ModuleId, stream, file.Name).ConfigureAwait(true); + + // Auto-fill form fields + if (string.IsNullOrEmpty(_name)) + { + _name = Path.GetFileNameWithoutExtension(file.Name); + } + if (string.IsNullOrEmpty(_fileName)) + { + _fileName = _uploadedFileName; + } + _fileSize = FormatFileSize(file.Size); + + _uploadProgress = 100; + AddModuleMessage("File uploaded successfully", MessageType.Success); } - _fileSize = FormatFileSize(file.Size); - - AddModuleMessage("File uploaded successfully", MessageType.Success); } catch (Exception ex) { @@ -133,6 +139,27 @@ private async Task OnFileSelected(InputFileChangeEventArgs e) } } + private void OnUploadProgress(UploadProgressArgs args) + { + _uploadProgress = args.Progress; + StateHasChanged(); + } + + private Task OnUploadComplete(UploadCompleteEventArgs args) + { + _isUploading = false; + StateHasChanged(); + return Task.CompletedTask; + } + + private Task OnUploadError(UploadErrorEventArgs args) + { + _isUploading = false; + AddModuleMessage($"Upload error: {args.Message}", MessageType.Error); + StateHasChanged(); + return Task.CompletedTask; + } + private static string FormatFileSize(long bytes) { string[] sizes = ["B", "KB", "MB", "GB", "TB"]; From 2111b481b364a8f7c285b5dc519fab13915f084a Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Thu, 25 Dec 2025 07:42:17 +1100 Subject: [PATCH 13/32] Refactor file/image upload to use Blazor InputFile --- Client/Modules/FileHub/Edit.razor | 78 ++++++++++----- Client/Modules/FileHub/Edit.razor.cs | 143 +++++++++++++++++++-------- Client/_Imports.razor | 1 + 3 files changed, 156 insertions(+), 66 deletions(-) diff --git a/Client/Modules/FileHub/Edit.razor b/Client/Modules/FileHub/Edit.razor index 0be6980..7fbb739 100644 --- a/Client/Modules/FileHub/Edit.razor +++ b/Client/Modules/FileHub/Edit.razor @@ -14,30 +14,31 @@
- - +
+ + +
@if (!string.IsNullOrEmpty(_uploadedFileName)) {
Uploaded: @_uploadedFileName
} - @if (_isUploading) - { -
- - Uploading... @_uploadProgress% -
- } +
Maximum file size: 100MB
@@ -52,24 +53,53 @@
- +
File name is required (max 255 characters)
+
Auto-filled from uploaded file
- +
- -
Image name is required (max 255 characters)
+
+ + +
+ @if (!string.IsNullOrEmpty(_uploadedImageName)) + { +
+ Uploaded: @_uploadedImageName +
+
+ Thumbnail preview +
+ } + +
Optional - Upload thumbnail/preview image (JPG, PNG, GIF - Max 10MB)
- +
- +
File size is required (max 12 characters)
+
Auto-filled from uploaded file
diff --git a/Client/Modules/FileHub/Edit.razor.cs b/Client/Modules/FileHub/Edit.razor.cs index e787ea6..0292741 100644 --- a/Client/Modules/FileHub/Edit.razor.cs +++ b/Client/Modules/FileHub/Edit.razor.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Components.Forms; using Radzen; -using Radzen.Blazor; namespace ICTAce.FileHub; @@ -30,6 +29,9 @@ public partial class Edit private bool _isUploading; private int _uploadProgress; private string? _uploadedFileName; + private bool _isUploadingImage; + private int _imageUploadProgress; + private string? _uploadedImageName; private int _id; private string _name = string.Empty; @@ -65,6 +67,7 @@ protected override async Task OnInitializedAsync() _name = file.Name; _fileName = file.FileName; _imageName = file.ImageName; + _uploadedImageName = file.ImageName; _description = file.Description; _fileSize = file.FileSize; _downloads = file.Downloads; @@ -89,7 +92,7 @@ protected override async Task OnInitializedAsync() } } - private async Task OnFileSelected(UploadChangeEventArgs args) + private async Task OnFileSelected(InputFileChangeEventArgs e) { try { @@ -97,35 +100,45 @@ private async Task OnFileSelected(UploadChangeEventArgs args) _uploadProgress = 0; StateHasChanged(); - foreach (var file in args.Files) + var file = e.File; + + // Limit file size to 100MB + const long maxFileSize = 100 * 1024 * 1024; + if (file.Size > maxFileSize) { - // Limit file size to 100MB - const long maxFileSize = 100 * 1024 * 1024; - if (file.Size > maxFileSize) - { - AddModuleMessage("File size exceeds 100MB limit", MessageType.Error); - _isUploading = false; - return; - } + AddModuleMessage("File size exceeds 100MB limit", MessageType.Error); + _isUploading = false; + return; + } - // Upload the file - using var stream = file.OpenReadStream(maxFileSize); - _uploadedFileName = await FileService.UploadFileAsync(ModuleState.ModuleId, stream, file.Name).ConfigureAwait(true); - - // Auto-fill form fields - if (string.IsNullOrEmpty(_name)) - { - _name = Path.GetFileNameWithoutExtension(file.Name); - } - if (string.IsNullOrEmpty(_fileName)) - { - _fileName = _uploadedFileName; - } - _fileSize = FormatFileSize(file.Size); - - _uploadProgress = 100; - AddModuleMessage("File uploaded successfully", MessageType.Success); + // Simulate progress for better UX + _uploadProgress = 10; + StateHasChanged(); + + // Upload the file + using var stream = file.OpenReadStream(maxFileSize); + + _uploadProgress = 30; + StateHasChanged(); + + _uploadedFileName = await FileService.UploadFileAsync(ModuleState.ModuleId, stream, file.Name).ConfigureAwait(true); + + _uploadProgress = 90; + StateHasChanged(); + + // Auto-fill form fields + if (string.IsNullOrEmpty(_name)) + { + _name = Path.GetFileNameWithoutExtension(file.Name); + } + if (string.IsNullOrEmpty(_fileName)) + { + _fileName = _uploadedFileName; } + _fileSize = FormatFileSize(file.Size); + + _uploadProgress = 100; + AddModuleMessage("File uploaded successfully", MessageType.Success); } catch (Exception ex) { @@ -139,25 +152,71 @@ private async Task OnFileSelected(UploadChangeEventArgs args) } } - private void OnUploadProgress(UploadProgressArgs args) + private async Task OnImageSelected(InputFileChangeEventArgs e) { - _uploadProgress = args.Progress; - StateHasChanged(); - } + try + { + _isUploadingImage = true; + _imageUploadProgress = 0; + StateHasChanged(); - private Task OnUploadComplete(UploadCompleteEventArgs args) - { - _isUploading = false; - StateHasChanged(); - return Task.CompletedTask; + var file = e.File; + + // Limit image size to 10MB + const long maxImageSize = 10 * 1024 * 1024; + if (file.Size > maxImageSize) + { + AddModuleMessage("Image size exceeds 10MB limit", MessageType.Error); + _isUploadingImage = false; + return; + } + + // Validate image type + if (!file.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + AddModuleMessage("Please select a valid image file", MessageType.Error); + _isUploadingImage = false; + return; + } + + // Simulate progress for better UX + _imageUploadProgress = 10; + StateHasChanged(); + + // Upload the image + using var stream = file.OpenReadStream(maxImageSize); + + _imageUploadProgress = 30; + StateHasChanged(); + + _uploadedImageName = await FileService.UploadFileAsync(ModuleState.ModuleId, stream, file.Name).ConfigureAwait(true); + _imageName = _uploadedImageName; + + _imageUploadProgress = 100; + AddModuleMessage("Image uploaded successfully", MessageType.Success); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Uploading Image {Error}", ex.Message).ConfigureAwait(true); + AddModuleMessage("Error uploading image", MessageType.Error); + } + finally + { + _isUploadingImage = false; + StateHasChanged(); + } } - private Task OnUploadError(UploadErrorEventArgs args) + private string GetImageUrl() { - _isUploading = false; - AddModuleMessage($"Upload error: {args.Message}", MessageType.Error); - StateHasChanged(); - return Task.CompletedTask; + if (string.IsNullOrEmpty(_uploadedImageName)) + { + return string.Empty; + } + + // Construct the URL to the uploaded image + // Path: Content/Tenants/{TenantId}/Sites/{SiteId}/FileHub/{ModuleId}/{filename} + return $"/Content/Tenants/{PageState.Alias.TenantId}/Sites/{PageState.Site.SiteId}/FileHub/{ModuleState.ModuleId}/{_uploadedImageName}"; } private static string FormatFileSize(long bytes) diff --git a/Client/_Imports.razor b/Client/_Imports.razor index 49aa3c3..e6311ec 100644 --- a/Client/_Imports.razor +++ b/Client/_Imports.razor @@ -1,5 +1,6 @@ @using ICTAce.FileHub.Services @using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using Microsoft.Extensions.Localization From 47b6533ae965b8f4430d15844c639f74cb5c3bac Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Sat, 27 Dec 2025 03:12:23 +1100 Subject: [PATCH 14/32] Add support for multi-category file associations --- Client/Modules/FileHub/Edit.razor | 3 +- Client/Modules/FileHub/Edit.razor.cs | 8 +-- Server/Features/Files/Controller.cs | 2 + Server/Features/Files/Create.cs | 41 ++++++++++++-- Server/Features/Files/Get.cs | 53 +++++++++++++++--- Server/Features/Files/Update.cs | 55 +++++++++++++++++-- Server/Persistence/ApplicationContext.cs | 6 +- Server/Persistence/Entities/Category.cs | 2 + Server/Persistence/Entities/File.cs | 2 + Server/Persistence/Entities/FileCategory.cs | 4 +- .../FileCategoryEntityBuilder.cs | 6 +- 11 files changed, 145 insertions(+), 37 deletions(-) diff --git a/Client/Modules/FileHub/Edit.razor b/Client/Modules/FileHub/Edit.razor index 7fbb739..d88794e 100644 --- a/Client/Modules/FileHub/Edit.razor +++ b/Client/Modules/FileHub/Edit.razor @@ -130,8 +130,7 @@ {
categories) } } - private void OnCategorySelectionChanged(TreeEventArgs args) - { - _selectedCategories = args.Value as IEnumerable; - StateHasChanged(); - } - - private async Task Save() + private async Task Save() { try { diff --git a/Server/Features/Files/Controller.cs b/Server/Features/Files/Controller.cs index 77d937c..d4bcf85 100644 --- a/Server/Features/Files/Controller.cs +++ b/Server/Features/Files/Controller.cs @@ -131,6 +131,7 @@ public async Task> CreateAsync( Description = dto.Description, FileSize = dto.FileSize, Downloads = dto.Downloads, + CategoryIds = dto.CategoryIds }; var id = await _mediator.Send(command, cancellationToken).ConfigureAwait(false); @@ -173,6 +174,7 @@ public async Task> UpdateAsync( Description = dto.Description, FileSize = dto.FileSize, Downloads = dto.Downloads, + CategoryIds = dto.CategoryIds }; var result = await _mediator.Send(command, cancellationToken).ConfigureAwait(false); diff --git a/Server/Features/Files/Create.cs b/Server/Features/Files/Create.cs index c7871d0..adb7bee 100644 --- a/Server/Features/Files/Create.cs +++ b/Server/Features/Files/Create.cs @@ -10,6 +10,7 @@ public record CreateFileRequest : RequestBase, IRequest public string? Description { get; set; } public string FileSize { get; set; } = string.Empty; public int Downloads { get; set; } + public List CategoryIds { get; set; } = []; } public class CreateHandler(HandlerServices services) @@ -17,13 +18,41 @@ public class CreateHandler(HandlerServices services) { private static readonly CreateMapper _mapper = new(); - public Task Handle(CreateFileRequest request, CancellationToken cancellationToken) + public async Task Handle(CreateFileRequest request, CancellationToken cancellationToken) { - return HandleCreateAsync( - request: request, - mapToEntity: _mapper.ToEntity, - cancellationToken: cancellationToken - ); + var alias = GetAlias(); + + if (!IsAuthorized(alias.SiteId, request.ModuleId, PermissionNames.Edit)) + { + Logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Add Attempt {ModuleId}", request.ModuleId); + return -1; + } + + var entity = _mapper.ToEntity(request); + + using var db = CreateDbContext(); + db.Set().Add(entity); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + // Save file-category relationships + if (request.CategoryIds.Any()) + { + foreach (var categoryId in request.CategoryIds) + { + var fileCategory = new Persistence.Entities.FileCategory + { + FileId = entity.Id, + CategoryId = categoryId, + CreatedBy = entity.CreatedBy, + CreatedOn = entity.CreatedOn + }; + db.Set().Add(fileCategory); + } + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + Logger.Log(LogLevel.Information, this, LogFunction.Create, "File Added {Entity}", entity); + return entity.Id; } } diff --git a/Server/Features/Files/Get.cs b/Server/Features/Files/Get.cs index a5df3f8..47580d3 100644 --- a/Server/Features/Files/Get.cs +++ b/Server/Features/Files/Get.cs @@ -2,25 +2,62 @@ namespace ICTAce.FileHub.Features.Files; -public record GetFileRequest : EntityRequestBase, IRequest; +public record GetFileRequest : EntityRequestBase, IRequest; + +public record GetFileDto +{ + public int Id { get; set; } + public int ModuleId { get; set; } + public required string Name { get; set; } + public required string FileName { get; set; } + public required string ImageName { get; set; } + public string? Description { get; set; } + public required string FileSize { get; set; } + public int Downloads { get; set; } + public List CategoryIds { get; set; } = []; + + public required string CreatedBy { get; set; } + public required DateTime CreatedOn { get; set; } + public required string ModifiedBy { get; set; } + public required DateTime ModifiedOn { get; set; } +} public class GetHandler(HandlerServices services) : HandlerBase(services), IRequestHandler { private static readonly GetMapper _mapper = new(); - public Task Handle(GetFileRequest request, CancellationToken cancellationToken) + public async Task Handle(GetFileRequest request, CancellationToken cancellationToken) { - return HandleGetAsync( - request: request, - mapToResponse: _mapper.ToGetResponse, - cancellationToken: cancellationToken - ); + var alias = GetAlias(); + + if (!IsAuthorized(alias.SiteId, request.ModuleId, PermissionNames.View)) + { + Logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Get Attempt {Id} {ModuleId}", request.Id, request.ModuleId); + return null; + } + + using var db = CreateDbContext(); + var entity = await db.Set() + .Include(f => f.FileCategories) + .SingleOrDefaultAsync(e => e.Id == request.Id && e.ModuleId == request.ModuleId, cancellationToken) + .ConfigureAwait(false); + + if (entity is null) + { + Logger.Log(LogLevel.Error, this, LogFunction.Read, "File not found {Id} {ModuleId}", request.Id, request.ModuleId); + return null; + } + + var dto = _mapper.ToDto(entity); + dto.CategoryIds = entity.FileCategories.Select(fc => fc.CategoryId).ToList(); + + return dto; } } [Mapper] internal sealed partial class GetMapper { - public partial GetFileDto ToGetResponse(ICTAce.FileHub.Persistence.Entities.File file); + internal partial GetFileDto ToDto(Persistence.Entities.File entity); } diff --git a/Server/Features/Files/Update.cs b/Server/Features/Files/Update.cs index 6a10122..f9a8bed 100644 --- a/Server/Features/Files/Update.cs +++ b/Server/Features/Files/Update.cs @@ -10,23 +10,66 @@ public record UpdateFileRequest : EntityRequestBase, IRequest public string? Description { get; set; } public required string FileSize { get; set; } public int Downloads { get; set; } + public List CategoryIds { get; set; } = []; } public class UpdateHandler(HandlerServices services) : HandlerBase(services), IRequestHandler { - public Task Handle(UpdateFileRequest request, CancellationToken cancellationToken) + public async Task Handle(UpdateFileRequest request, CancellationToken cancellationToken) { - return HandleUpdateAsync( - request: request, - setPropertyCalls: setter => setter + var alias = GetAlias(); + + if (!IsAuthorized(alias.SiteId, request.ModuleId, PermissionNames.Edit)) + { + Logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Update Attempt {Id}", request.Id); + return -1; + } + + using var db = CreateDbContext(); + + // Update file properties + var rowsAffected = await db.Set() + .Where(e => e.Id == request.Id && e.ModuleId == request.ModuleId) + .ExecuteUpdateAsync(setter => setter .SetProperty(e => e.Name, request.Name) .SetProperty(e => e.FileName, request.FileName) .SetProperty(e => e.ImageName, request.ImageName) .SetProperty(e => e.Description, request.Description) .SetProperty(e => e.FileSize, request.FileSize) .SetProperty(e => e.Downloads, request.Downloads), - cancellationToken: cancellationToken - ); + cancellationToken) + .ConfigureAwait(false); + + if (rowsAffected > 0) + { + // Update file-category relationships + // First, remove existing relationships + await db.Set() + .Where(fc => fc.FileId == request.Id) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + // Then, add new relationships + if (request.CategoryIds.Any()) + { + foreach (var categoryId in request.CategoryIds) + { + var fileCategory = new Persistence.Entities.FileCategory + { + FileId = request.Id, + CategoryId = categoryId + }; + db.Set().Add(fileCategory); + } + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + Logger.Log(LogLevel.Information, this, LogFunction.Update, "File Updated {Id}", request.Id); + return request.Id; + } + + Logger.Log(LogLevel.Warning, this, LogFunction.Update, "File Not Found {Id}", request.Id); + return -1; } } diff --git a/Server/Persistence/ApplicationContext.cs b/Server/Persistence/ApplicationContext.cs index c2f56da..ceb245e 100644 --- a/Server/Persistence/ApplicationContext.cs +++ b/Server/Persistence/ApplicationContext.cs @@ -34,12 +34,12 @@ protected override void OnModelCreating(ModelBuilder builder) entity.ToTable(ActiveDatabase.RewriteName("ICTAce_FileHub_FileCategory")); entity.HasOne(fc => fc.FileHub) - .WithMany() - .HasForeignKey(fc => fc.FileHubId) + .WithMany(f => f.FileCategories) + .HasForeignKey(fc => fc.FileId) .OnDelete(DeleteBehavior.Cascade); entity.HasOne(fc => fc.Category) - .WithMany() + .WithMany(c => c.FileCategories) .HasForeignKey(fc => fc.CategoryId) .OnDelete(DeleteBehavior.Restrict); }); diff --git a/Server/Persistence/Entities/Category.cs b/Server/Persistence/Entities/Category.cs index e245a84..5c4b7eb 100644 --- a/Server/Persistence/Entities/Category.cs +++ b/Server/Persistence/Entities/Category.cs @@ -14,4 +14,6 @@ public class Category : AuditableModuleBase public Category? ParentCategory { get; set; } public ICollection? Subcategories { get; set; } + + public ICollection FileCategories { get; set; } = []; } diff --git a/Server/Persistence/Entities/File.cs b/Server/Persistence/Entities/File.cs index 45429ad..5c2ffb1 100644 --- a/Server/Persistence/Entities/File.cs +++ b/Server/Persistence/Entities/File.cs @@ -21,4 +21,6 @@ public class File : AuditableModuleBase public required string FileSize { get; set; } public int Downloads { get; set; } + + public ICollection FileCategories { get; set; } = []; } diff --git a/Server/Persistence/Entities/FileCategory.cs b/Server/Persistence/Entities/FileCategory.cs index 956100c..6d1b0d4 100644 --- a/Server/Persistence/Entities/FileCategory.cs +++ b/Server/Persistence/Entities/FileCategory.cs @@ -2,12 +2,12 @@ namespace ICTAce.FileHub.Persistence.Entities; -public class FileCategory +public class FileCategory : AuditableModuleBase { [Key] public int Id { get; set; } - public int FileHubId { get; set; } + public int FileId { get; set; } public File FileHub { get; set; } = null!; public int CategoryId { get; set; } diff --git a/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs b/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs index f48c7a7..4107917 100644 --- a/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs +++ b/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs @@ -6,7 +6,7 @@ public class FileCategoryEntityBuilder : AuditableBaseEntityBuilder _primaryKey = new("PK_ICTAce_FileHub_FileCategory", x => x.Id); - private readonly ForeignKey _fileForeignKey = new("FK_ICTAce_FileHub_FileCategory_File", x => x.FileHubId, "ICTAce_FileHub_File", "Id", ReferentialAction.Cascade); + private readonly ForeignKey _fileForeignKey = new("FK_ICTAce_FileHub_FileCategory_File", x => x.FileId, "ICTAce_FileHub_File", "Id", ReferentialAction.Cascade); private readonly ForeignKey _categoryForeignKey = new("FK_ICTAce_FileHub_FileCategory_Category", x => x.CategoryId, "ICTAce_FileHub_Category", "Id", ReferentialAction.Restrict); public FileCategoryEntityBuilder(MigrationBuilder migrationBuilder, IDatabase database) : base(migrationBuilder, database) @@ -20,13 +20,13 @@ public FileCategoryEntityBuilder(MigrationBuilder migrationBuilder, IDatabase da protected override FileCategoryEntityBuilder BuildTable(ColumnsBuilder table) { Id = AddAutoIncrementColumn(table, "Id"); - FileHubId = AddIntegerColumn(table, "FileHubId"); + FileId = AddIntegerColumn(table, "FileId"); CategoryId = AddIntegerColumn(table, "CategoryId"); AddAuditableColumns(table); return this; } public OperationBuilder Id { get; set; } - public OperationBuilder FileHubId { get; set; } + public OperationBuilder FileId { get; set; } public OperationBuilder CategoryId { get; set; } } From 33c5f5303928b307f41a89e33f2c9277513693b4 Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Sat, 27 Dec 2025 06:54:47 +1100 Subject: [PATCH 15/32] Add ModuleId to FileCategory entity and schema #18 --- Server/Features/Files/Create.cs | 3 ++- .../Migrations/EntityBuilders/FileCategoryEntityBuilder.cs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Server/Features/Files/Create.cs b/Server/Features/Files/Create.cs index adb7bee..d699e9c 100644 --- a/Server/Features/Files/Create.cs +++ b/Server/Features/Files/Create.cs @@ -43,12 +43,13 @@ public async Task Handle(CreateFileRequest request, CancellationToken cance { FileId = entity.Id, CategoryId = categoryId, + ModuleId = entity.ModuleId, CreatedBy = entity.CreatedBy, CreatedOn = entity.CreatedOn }; db.Set().Add(fileCategory); } - await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + var result = await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } Logger.Log(LogLevel.Information, this, LogFunction.Create, "File Added {Entity}", entity); diff --git a/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs b/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs index 4107917..7029b3d 100644 --- a/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs +++ b/Server/Persistence/Migrations/EntityBuilders/FileCategoryEntityBuilder.cs @@ -20,6 +20,7 @@ public FileCategoryEntityBuilder(MigrationBuilder migrationBuilder, IDatabase da protected override FileCategoryEntityBuilder BuildTable(ColumnsBuilder table) { Id = AddAutoIncrementColumn(table, "Id"); + ModuleId = AddIntegerColumn(table, "ModuleId"); FileId = AddIntegerColumn(table, "FileId"); CategoryId = AddIntegerColumn(table, "CategoryId"); AddAuditableColumns(table); @@ -27,6 +28,7 @@ protected override FileCategoryEntityBuilder BuildTable(ColumnsBuilder table) } public OperationBuilder Id { get; set; } + public OperationBuilder ModuleId { get; set; } public OperationBuilder FileId { get; set; } public OperationBuilder CategoryId { get; set; } } From f44978a5c0358a22a0d2d90d5b8cd047a9a38132 Mon Sep 17 00:00:00 2001 From: Behnam Emamian Date: Mon, 29 Dec 2025 00:15:31 +1100 Subject: [PATCH 16/32] update nugets --- Directory.Packages.props | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 021a895..bc5ff2f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,22 +13,22 @@ - + - + - - - + + + - + From 3c4fa7464529b11e5eeb7f40addd2a1dd07d1f45 Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Mon, 29 Dec 2025 08:05:48 +1100 Subject: [PATCH 17/32] Refactor file listing to use Radzen cards and controls for a more attractive --- Client/Modules/FileHub/Edit.razor | 28 ++--- Client/Modules/FileHub/Edit.razor.cs | 9 +- Client/Modules/FileHub/Index.razor | 102 ++++++++++++++---- Client/Modules/FileHub/Index.razor.cs | 61 +++++++++-- Server/Features/Files/GetByFileName.cs | 31 ++++++ .../wwwroot/Modules/ICTAce.FileHub/Module.css | 65 +++++++++++ 6 files changed, 239 insertions(+), 57 deletions(-) create mode 100644 Server/Features/Files/GetByFileName.cs diff --git a/Client/Modules/FileHub/Edit.razor b/Client/Modules/FileHub/Edit.razor index d88794e..8f70ce6 100644 --- a/Client/Modules/FileHub/Edit.razor +++ b/Client/Modules/FileHub/Edit.razor @@ -3,7 +3,7 @@ -
+
@@ -15,8 +15,8 @@
- @@ -35,7 +35,7 @@ @if (!string.IsNullOrEmpty(_uploadedFileName)) {
- Uploaded: @_uploadedFileName + Uploaded
}
Maximum file size: 100MB
@@ -49,22 +49,13 @@
Name is required (max 100 characters)
- -
- -
- -
File name is required (max 255 characters)
-
Auto-filled from uploaded file
-
-
- +
- @@ -83,10 +74,7 @@ @if (!string.IsNullOrEmpty(_uploadedImageName)) {
- Uploaded: @_uploadedImageName -
-
- Thumbnail preview + Uploaded
} diff --git a/Client/Modules/FileHub/Edit.razor.cs b/Client/Modules/FileHub/Edit.razor.cs index 9f7c74a..ef019af 100644 --- a/Client/Modules/FileHub/Edit.razor.cs +++ b/Client/Modules/FileHub/Edit.razor.cs @@ -127,10 +127,6 @@ private async Task OnFileSelected(InputFileChangeEventArgs e) StateHasChanged(); // Auto-fill form fields - if (string.IsNullOrEmpty(_name)) - { - _name = Path.GetFileNameWithoutExtension(file.Name); - } if (string.IsNullOrEmpty(_fileName)) { _fileName = _uploadedFileName; @@ -214,9 +210,8 @@ private string GetImageUrl() return string.Empty; } - // Construct the URL to the uploaded image - // Path: Content/Tenants/{TenantId}/Sites/{SiteId}/FileHub/{ModuleId}/{filename} - return $"/Content/Tenants/{PageState.Alias.TenantId}/Sites/{PageState.Site.SiteId}/FileHub/{ModuleState.ModuleId}/{_uploadedImageName}"; + // Use the serve endpoint - moduleId will be looked up server-side + return $"/api/ictace/fileHub/files/serve/{Uri.EscapeDataString(_uploadedImageName)}"; } private static string FormatFileSize(long bytes) diff --git a/Client/Modules/FileHub/Index.razor b/Client/Modules/FileHub/Index.razor index 012b14f..d11edac 100644 --- a/Client/Modules/FileHub/Index.razor +++ b/Client/Modules/FileHub/Index.razor @@ -1,32 +1,96 @@ @namespace ICTAce.FileHub @inherits ModuleBase -@if (_filehubs == null) + + +@if (_isLoading) { -

Loading...

+
+ + + +
} else { - -
-
- @if (_filehubs.Count != 0) +
+ +
+ + @if (_files == null || !_files.Any()) { - -
-   -   - @Localizer["Name"] -
- - - - @context.Name - -
+
+ @Localizer["Message.DisplayNone"] +
} else { -

@Localizer["Message.DisplayNone"]

+ + + } } diff --git a/Client/Modules/FileHub/Index.razor.cs b/Client/Modules/FileHub/Index.razor.cs index d9a3ba3..684bd0b 100644 --- a/Client/Modules/FileHub/Index.razor.cs +++ b/Client/Modules/FileHub/Index.razor.cs @@ -4,7 +4,7 @@ namespace ICTAce.FileHub; public partial class Index { - [Inject] protected ISampleModuleService FileHubService { get; set; } = default!; + [Inject] protected Services.IFileService FileService { get; set; } = default!; [Inject] protected NavigationManager NavigationManager { get; set; } = default!; [Inject] protected IStringLocalizer Localizer { get; set; } = default!; @@ -12,39 +12,78 @@ public partial class Index [ new Stylesheet(ModulePath() + "Module.css"), new Script(ModulePath() + "Module.js"), + new Script("_content/Radzen.Blazor/Radzen.Blazor.js") ]; - private List? _filehubs; + private List? _files; + private bool _isLoading = true; protected override async Task OnInitializedAsync() { try { - var pagedResult = await FileHubService.ListAsync(ModuleState.ModuleId).ConfigureAwait(true); - _filehubs = pagedResult?.Items?.ToList(); + _isLoading = true; + var pagedResult = await FileService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue).ConfigureAwait(true); + _files = pagedResult?.Items?.ToList(); } catch (Exception ex) { - await logger.LogError(ex, "Error Loading FileHub {Error}", ex.Message).ConfigureAwait(true); + await logger.LogError(ex, "Error Loading Files {Error}", ex.Message).ConfigureAwait(true); AddModuleMessage(Localizer["Message.LoadError"], MessageType.Error); } + finally + { + _isLoading = false; + } } - private async Task Delete(ListSampleModuleDto filehub) + private async Task Delete(ListFileDto file) { try { - await FileHubService.DeleteAsync(filehub.Id, ModuleState.ModuleId).ConfigureAwait(true); - await logger.LogInformation("FileHub Deleted {Id}", filehub.Id).ConfigureAwait(true); + await FileService.DeleteAsync(file.Id, ModuleState.ModuleId).ConfigureAwait(true); + await logger.LogInformation("File Deleted {Id}", file.Id).ConfigureAwait(true); - var pagedResult = await FileHubService.ListAsync(ModuleState.ModuleId).ConfigureAwait(true); - _filehubs = pagedResult?.Items?.ToList(); + var pagedResult = await FileService.ListAsync(ModuleState.ModuleId, pageNumber: 1, pageSize: int.MaxValue).ConfigureAwait(true); + _files = pagedResult?.Items?.ToList(); StateHasChanged(); } catch (Exception ex) { - await logger.LogError(ex, "Error Deleting FileHub {Id} {Error}", filehub.Id, ex.Message).ConfigureAwait(true); + await logger.LogError(ex, "Error Deleting File {Id} {Error}", file.Id, ex.Message).ConfigureAwait(true); AddModuleMessage(Localizer["Message.DeleteError"], MessageType.Error); } } + + private void NavigateToEdit(int fileId) + { + NavigationManager.NavigateTo(EditUrl("id", fileId.ToString())); + } + + private void NavigateToAdd() + { + NavigationManager.NavigateTo(EditUrl("Add")); + } + + private string GetImageUrl(string imageName) + { + if (string.IsNullOrEmpty(imageName)) + { + return string.Empty; + } + + // Use the serve endpoint - moduleId will be looked up server-side + return $"/api/ictace/fileHub/files/serve/{Uri.EscapeDataString(imageName)}"; + } + + private string GetDownloadUrl(string fileName) + { + if (string.IsNullOrEmpty(fileName)) + { + return string.Empty; + } + + // Use the serve endpoint - moduleId will be looked up server-side + return $"/api/ictace/fileHub/files/serve/{Uri.EscapeDataString(fileName)}"; + } } diff --git a/Server/Features/Files/GetByFileName.cs b/Server/Features/Files/GetByFileName.cs new file mode 100644 index 0000000..5f79b31 --- /dev/null +++ b/Server/Features/Files/GetByFileName.cs @@ -0,0 +1,31 @@ +// Licensed to ICTAce under the MIT license. + +namespace ICTAce.FileHub.Features.Files; + +public record GetFileByFileNameRequest : IRequest +{ + public required string FileName { get; set; } +} + +public record FileModuleInfo +{ + public int ModuleId { get; set; } +} + +public class GetByFileNameHandler(HandlerServices services) + : HandlerBase(services), IRequestHandler +{ + public async Task Handle(GetFileByFileNameRequest request, CancellationToken cancellationToken) + { + using var db = CreateDbContext(); + + // Look up file by FileName or ImageName + var file = await db.File + .Where(f => f.FileName == request.FileName || f.ImageName == request.FileName) + .Select(f => new FileModuleInfo { ModuleId = f.ModuleId }) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + return file; + } +} diff --git a/Server/wwwroot/Modules/ICTAce.FileHub/Module.css b/Server/wwwroot/Modules/ICTAce.FileHub/Module.css index f9012a8..f7df78a 100644 --- a/Server/wwwroot/Modules/ICTAce.FileHub/Module.css +++ b/Server/wwwroot/Modules/ICTAce.FileHub/Module.css @@ -4,3 +4,68 @@ div.rz-tooltip.rz-open.rz-popup { z-index: 99999 !important; } + +/* File Card Styling */ +.rz-card { + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + border: 1px solid rgba(0, 0, 0, 0.12); + margin-bottom: 1rem; + cursor: pointer; +} + +.rz-card:hover { + transform: translateY(-4px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +/* File thumbnail placeholder */ +.file-thumbnail-placeholder { + width: 150px; + height: 150px; + background-color: rgba(0, 0, 0, 0.03); + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 2px dashed rgba(0, 0, 0, 0.12); +} + +/* File card content */ +.file-card-content h5 { + font-weight: 600; + margin-bottom: 0.5rem; +} + +.file-card-content .text-muted { + opacity: 0.7; + font-size: 0.9rem; +} + +/* Badge spacing */ +.rz-badge { + margin-right: 0.5rem; + margin-bottom: 0.25rem; +} + +/* DataList pagination */ +.rz-datalist-pager { + margin-top: 1.5rem; +} + +/* Image styling */ +.file-card-image { + width: 150px; + height: 150px; + object-fit: cover; + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.12); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .file-thumbnail-placeholder, + .file-card-image { + width: 100px !important; + height: 100px !important; + } +} From 477b065d773441999e983c9e358d233f494bdfbb Mon Sep 17 00:00:00 2001 From: Alireza Tavakoli Date: Tue, 30 Dec 2025 01:23:54 +1100 Subject: [PATCH 18/32] Refactor file form and card UI for clarity and usability #18 --- Client/Modules/FileHub/Edit.razor | 29 ++++++++++------------------- Client/Modules/FileHub/Index.razor | 25 +++++++------------------ 2 files changed, 17 insertions(+), 37 deletions(-) diff --git a/Client/Modules/FileHub/Edit.razor b/Client/Modules/FileHub/Edit.razor index 8f70ce6..1ff6e9f 100644 --- a/Client/Modules/FileHub/Edit.razor +++ b/Client/Modules/FileHub/Edit.razor @@ -74,23 +74,13 @@ @if (!string.IsNullOrEmpty(_uploadedImageName)) {
- Uploaded + Uploaded - size: @_fileSize
}
Optional - Upload thumbnail/preview image (JPG, PNG, GIF - Max 10MB)
- -
- -
- -
File size is required (max 12 characters)
-
Auto-filled from uploaded file
-
-
-
@@ -98,15 +88,16 @@
Optional - max 1000 characters
- -
- -
- -
Downloads must be 0 or greater
+ @if (string.Equals(PageState.Action, "Edit", StringComparison.Ordinal)) + { +
+ +
+ +
Downloads must be 0 or greater
+
-
- + }
diff --git a/Client/Modules/FileHub/Index.razor b/Client/Modules/FileHub/Index.razor index d11edac..1656b71 100644 --- a/Client/Modules/FileHub/Index.razor +++ b/Client/Modules/FileHub/Index.razor @@ -32,7 +32,7 @@ else PagerHorizontalAlign="HorizontalAlign.Center" ShowPagingSummary="true">