diff --git a/src/Application/Common/DTOs/Folders/FolderInDto.cs b/src/Application/Common/DTOs/Folders/FolderInDto.cs new file mode 100644 index 0000000..8ac72ed --- /dev/null +++ b/src/Application/Common/DTOs/Folders/FolderInDto.cs @@ -0,0 +1,18 @@ +namespace Application.Common.DTOs.Folders; + +public class FolderInDto +{ + public string Guid { get; set; } + + public string Name { get; set; } + + public string Color { get; set; } + + public string Icon { get; set; } + + public string Description { get; set; } + + public string LastModified { get; set; } + + public ICollection Children { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/Application/Common/DTOs/Folders/FolderOutDto.cs b/src/Application/Common/DTOs/Folders/FolderOutDto.cs new file mode 100644 index 0000000..927477a --- /dev/null +++ b/src/Application/Common/DTOs/Folders/FolderOutDto.cs @@ -0,0 +1,20 @@ +using System.Collections.ObjectModel; + +namespace Application.Common.DTOs.Folders; + +public class FolderOutDto +{ + public string Guid { get; set; } + + public string Name { get; set; } + + public string Color { get; set; } + + public string Icon { get; set; } + + public string Description { get; set; } + + public string LastModified { get; set; } + + public ICollection Children { get; set; } = new Collection(); +} \ No newline at end of file diff --git a/src/Application/Interfaces/Repositories/IFolderRepository.cs b/src/Application/Interfaces/Repositories/IFolderRepository.cs new file mode 100644 index 0000000..d0f3b97 --- /dev/null +++ b/src/Application/Interfaces/Repositories/IFolderRepository.cs @@ -0,0 +1,11 @@ +using Domain.Entities; + +namespace Application.Interfaces.Repositories; + +public interface IFolderRepository +{ + public Task SaveChangesAsync(); + public Task GetFolderAsync(Guid folderId); + public Task CreateFolderAsync(Folder folder); + public void RemoveFolder(Folder folder); +} \ No newline at end of file diff --git a/src/Application/Interfaces/Services/IFolderService.cs b/src/Application/Interfaces/Services/IFolderService.cs new file mode 100644 index 0000000..45f2bd9 --- /dev/null +++ b/src/Application/Interfaces/Services/IFolderService.cs @@ -0,0 +1,9 @@ +using Application.Common.DTOs.Folders; + +namespace Application.Interfaces.Services; + +public interface IFolderService +{ + public Task UpdateFoldersAsync(string email, FolderInDto folderInDto); + public Task GetFoldersAsync(string email); +} \ No newline at end of file diff --git a/src/Application/Services/FolderService.cs b/src/Application/Services/FolderService.cs new file mode 100644 index 0000000..73357cb --- /dev/null +++ b/src/Application/Services/FolderService.cs @@ -0,0 +1,102 @@ +using Application.Common.DTOs.Folders; +using Application.Common.Exceptions; +using Application.Interfaces.Repositories; +using Application.Interfaces.Services; +using Domain.Entities; + +namespace Application.Services; + +public class FolderService(IUserRepository userRepository, IFolderRepository folderRepository) : IFolderService +{ + public IUserRepository UserRepository { get; } = userRepository; + public IFolderRepository FolderRepository { get; } = folderRepository; + + public async Task UpdateFoldersAsync(string email, FolderInDto folderInDto) + { + var user = await UserRepository.GetAsync(email, true); + if (user == null) + throw new CommonErrorException(400, "No user with this email exists", 17); + + var folder = FolderInDtoToFolder(folderInDto); + // If the user has no root folder, create it and set it as the user's root folder + if(user.RootFolderId == Guid.Empty) + { + await FolderRepository.CreateFolderAsync(folder); + user.RootFolderId = folder.FolderId; + + await UserRepository.SaveChangesAsync(); + return; + } + + if(folder.FolderId != user.RootFolderId) + throw new CommonErrorException(400, "Folder id does not match user root folder id", 0); + + var existingFolder = await FolderRepository.GetFolderAsync(user.RootFolderId); + if(existingFolder == null) + throw new CommonErrorException(400, "User has no root folder", 0); + + FolderRepository.RemoveFolder(existingFolder); + await FolderRepository.CreateFolderAsync(folder); + await FolderRepository.SaveChangesAsync(); + } + + private Folder FolderInDtoToFolder(FolderInDto folderInDto) + { + var folder = new Folder + { + FolderId = new Guid(folderInDto.Guid), + Name = folderInDto.Name, + Color = folderInDto.Color, + Icon = folderInDto.Icon, + Description = folderInDto.Description, + LastModified = folderInDto.LastModified, + Children = new List() + }; + + foreach (var child in folderInDto.Children) + { + var childFolder = FolderInDtoToFolder(child); + folder.Children.Add(childFolder); + } + + return folder; + } + + public async Task GetFoldersAsync(string email) + { + var user = await UserRepository.GetAsync(email, false); + if (user == null) + { + throw new CommonErrorException(400, "No user with this email exists", 17); + } + + if(user.RootFolderId == Guid.Empty) + { + throw new CommonErrorException(400, "User has no root folder", 22); + } + + var folder = await FolderRepository.GetFolderAsync(user.RootFolderId); + return await FolderToFolderOutDto(folder); + } + + private async Task FolderToFolderOutDto(Folder folder) + { + var folderOutDto = new FolderOutDto + { + Guid = folder.FolderId.ToString(), + Name = folder.Name, + Color = folder.Color, + Icon = folder.Icon, + LastModified = folder.LastModified, + Description = folder.Description + }; + + foreach (var child in folder.Children) + { + var childFolderOutDto = await FolderToFolderOutDto(child); + folderOutDto.Children.Add(childFolderOutDto); + } + + return folderOutDto; + } +} \ No newline at end of file diff --git a/src/Domain/Entities/Folder.cs b/src/Domain/Entities/Folder.cs new file mode 100644 index 0000000..2a5a2d5 --- /dev/null +++ b/src/Domain/Entities/Folder.cs @@ -0,0 +1,30 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Domain.Entities; + +public class Folder +{ + [DatabaseGenerated(DatabaseGeneratedOption.None)] + [Key] + public Guid FolderId { get; set; } + + [Required] + public string Name { get; set; } + + [Required] + public string Color { get; set; } + + [Required] + public string Icon { get; set; } + + public string Description { get; set; } + + [Required] + public string LastModified { get; set; } + + public Guid? ParentFolderId { get; set; } + public Folder? ParentFolder { get; set; } + public List? Children { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/Domain/Entities/User.cs b/src/Domain/Entities/User.cs index 050ab52..d7ccc24 100644 --- a/src/Domain/Entities/User.cs +++ b/src/Domain/Entities/User.cs @@ -39,6 +39,8 @@ public class User : IdentityUser [Required] public int AiExplanationRequestsMadeToday { get; set; } = 0; + public Guid RootFolderId { get; set; } + public ICollection Books { get; set; } public ICollection Tags { get; set; } } \ No newline at end of file diff --git a/src/Infrastructure/Persistence/DataContext.cs b/src/Infrastructure/Persistence/DataContext.cs index b5578d6..1383060 100644 --- a/src/Infrastructure/Persistence/DataContext.cs +++ b/src/Infrastructure/Persistence/DataContext.cs @@ -12,6 +12,7 @@ public class DataContext : IdentityDbContext public DbSet Bookmarks { get; set; } public DbSet Tags { get; set; } public DbSet Products { get; set; } + public DbSet Folders { get; set; } public DataContext(DbContextOptions options) : base(options) diff --git a/src/Infrastructure/Persistence/EntityConfigurations/FolderConfiguration.cs b/src/Infrastructure/Persistence/EntityConfigurations/FolderConfiguration.cs new file mode 100644 index 0000000..9f4075f --- /dev/null +++ b/src/Infrastructure/Persistence/EntityConfigurations/FolderConfiguration.cs @@ -0,0 +1,17 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Persistence.EntityConfigurations; + +public class FolderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder + .HasOne(f => f.ParentFolder) + .WithMany(f => f.Children) + .HasForeignKey(f => f.ParentFolderId) + .OnDelete(DeleteBehavior.NoAction); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Migrations/DataContextModelSnapshot.cs b/src/Infrastructure/Persistence/Migrations/DataContextModelSnapshot.cs index c400baf..0c24834 100644 --- a/src/Infrastructure/Persistence/Migrations/DataContextModelSnapshot.cs +++ b/src/Infrastructure/Persistence/Migrations/DataContextModelSnapshot.cs @@ -144,6 +144,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Bookmarks"); }); + modelBuilder.Entity("Domain.Entities.Folder", b => + { + b.Property("FolderId") + .HasColumnType("uniqueidentifier"); + + b.Property("Color") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Icon") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LastModified") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ParentFolderId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("FolderId"); + + b.HasIndex("ParentFolderId"); + + b.ToTable("Folders"); + }); + modelBuilder.Entity("Domain.Entities.Highlight", b => { b.Property("HighlightId") @@ -349,6 +384,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ProfilePictureLastUpdated") .HasColumnType("datetime2"); + b.Property("RootFolderId") + .HasColumnType("uniqueidentifier"); + b.Property("SecurityStamp") .HasColumnType("nvarchar(max)"); @@ -530,6 +568,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Book"); }); + modelBuilder.Entity("Domain.Entities.Folder", b => + { + b.HasOne("Domain.Entities.Folder", "ParentFolder") + .WithMany("Children") + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("ParentFolder"); + }); + modelBuilder.Entity("Domain.Entities.Highlight", b => { b.HasOne("Domain.Entities.Book", "Book") @@ -638,6 +686,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Tags"); }); + modelBuilder.Entity("Domain.Entities.Folder", b => + { + b.Navigation("Children"); + }); + modelBuilder.Entity("Domain.Entities.Highlight", b => { b.Navigation("Rects"); diff --git a/src/Infrastructure/Persistence/Repository/FolderRepository.cs b/src/Infrastructure/Persistence/Repository/FolderRepository.cs new file mode 100644 index 0000000..bc4a67f --- /dev/null +++ b/src/Infrastructure/Persistence/Repository/FolderRepository.cs @@ -0,0 +1,50 @@ +using Application.Interfaces.Repositories; +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Persistence.Repository; + +public class FolderRepository(DataContext context) : IFolderRepository +{ + public DataContext Context { get; } = context; + + public async Task SaveChangesAsync() + { + await Context.SaveChangesAsync(); + } + + public async Task GetFolderAsync(Guid folderId) + { + return await Context.Folders + .Where(f => f.FolderId == folderId) + .Include(f => f.Children) + .ThenInclude(f => f.Children) + .ThenInclude(f => f.Children) + .ThenInclude(f => f.Children) + .ThenInclude(f => f.Children) + .FirstOrDefaultAsync(); + } + + public async Task CreateFolderAsync(Folder folder) + { + await Context.Folders.AddAsync(folder); + } + + public void RemoveFolder(Folder folder) + { + var allFolders = new List(); + GetAllChildren(folder, allFolders); + allFolders.Add(folder); + + Context.Folders.RemoveRange(allFolders); + } + + private void GetAllChildren(Folder folder, List allChildren) + { + allChildren.Add(folder); + foreach (var child in folder.Children) + { + GetAllChildren(child, allChildren); + } + } +} \ No newline at end of file diff --git a/src/Presentation/Controllers/FolderController.cs b/src/Presentation/Controllers/FolderController.cs new file mode 100644 index 0000000..fd73449 --- /dev/null +++ b/src/Presentation/Controllers/FolderController.cs @@ -0,0 +1,49 @@ +using Application.Common.DTOs.Folders; +using Application.Common.Exceptions; +using Application.Interfaces.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace Presentation.Controllers; + +[Authorize] +[ApiController] +[Route("[controller]")] +public class FolderController(IFolderService folderService, ILogger logger) : ControllerBase +{ + private IFolderService FolderService { get; } = folderService; + private ILogger Logger { get; } = logger; + + [HttpGet] + public async Task GetFolders() + { + try + { + var email = HttpContext.User.Identity!.Name; + var folders = await FolderService.GetFoldersAsync(email); + return Ok(folders); + } + catch (CommonErrorException e) + { + Logger.LogWarning("{ErrorMessage}", e.Message); + return StatusCode(e.Error.Status, e.Error); + } + } + + [HttpPost("update")] + public async Task UpdateFolders([FromBody] FolderInDto folderInDto) + { + try + { + var email = HttpContext.User.Identity!.Name; + await FolderService.UpdateFoldersAsync(email, folderInDto); + return Ok(); + } + catch (CommonErrorException e) + { + Logger.LogWarning("{ErrorMessage}", e.Message); + return StatusCode(e.Error.Status, e.Error); + } + } +} \ No newline at end of file diff --git a/src/Presentation/DependencyInjection.cs b/src/Presentation/DependencyInjection.cs index 386ef18..e9d48cc 100644 --- a/src/Presentation/DependencyInjection.cs +++ b/src/Presentation/DependencyInjection.cs @@ -43,6 +43,8 @@ public static IServiceCollection AddApplicationServices( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService();