diff --git a/WardrobeManager.Api.Tests/Services/ClothingServiceTests.cs b/WardrobeManager.Api.Tests/Services/ClothingServiceTests.cs index 7f80e69..9ecfab6 100644 --- a/WardrobeManager.Api.Tests/Services/ClothingServiceTests.cs +++ b/WardrobeManager.Api.Tests/Services/ClothingServiceTests.cs @@ -1,4 +1,5 @@ -using FluentAssertions; +using AutoMapper; +using FluentAssertions; using FluentAssertions.Execution; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; @@ -8,7 +9,9 @@ using WardrobeManager.Api.Repositories.Interfaces; using WardrobeManager.Api.Services.Implementation; using WardrobeManager.Api.Services.Interfaces; +using WardrobeManager.Shared.DTOs; using WardrobeManager.Shared.Enums; +using WardrobeManager.Shared.StaticResources; namespace WardrobeManager.Api.Tests.Services; @@ -16,8 +19,13 @@ public class ClothingServiceTests { private const string DefaultUserId = "test-userid"; private const int DefaultItemId = 1; + private const string ValidBase64 = "YQ=="; // a private Mock _mockRepo; + private Mock _mockMapper; + private FakeLogger _fakeLogger; + private Mock _mockFileService; + private Mock _mockMiscMethods; private ClothingService _serviceToTest; [SetUp] @@ -25,7 +33,13 @@ public void Setup() { // Mock the service and its dependencies _mockRepo = new Mock(); - _serviceToTest = new ClothingService(_mockRepo.Object); + _mockMapper = new Mock(); + _fakeLogger = new FakeLogger(); + _mockFileService = new Mock(); + _mockMiscMethods = new Mock(); + + _serviceToTest = new ClothingService(_mockRepo.Object, _mockMapper.Object, _mockFileService.Object, + _fakeLogger, _mockMiscMethods.Object); } #region GetClothingItem @@ -35,14 +49,19 @@ public async Task GetClothingItem_ClothingItemExists_ReturnsClothingItem() { // Arrange var clothingItem = CreateSampleClothingItem(); + var clothingItemDto = CreateSampleClothingItemDTO(); _mockRepo .Setup(r => r.GetAsync(clothingItem.UserId, clothingItem.Id)) .ReturnsAsync(clothingItem); - // Act + _mockMapper + .Setup(m => m.Map(clothingItem)) + .Returns(clothingItemDto); + +// Act var result = await _serviceToTest.GetClothingItemAsync(clothingItem.UserId, clothingItem.Id); - // Assert - _mockRepo.Verify(r => r.GetAsync(clothingItem.UserId, clothingItem.Id), Times.Once); - result.Should().BeEquivalentTo(clothingItem); + +// Assert + result.Should().BeEquivalentTo(clothingItemDto); } [Test] @@ -68,15 +87,21 @@ public async Task GetClothingItem_ClothingItemDoesNotExist_ReturnsNull() public async Task GetAllClothingAsync_OneItemExists_ReturnsOneItem() { // Arrange - var items = CreateSampleClothingItems(1); + var items = CreateSampleClothingItems(5); + var itemsDto = CreateSampleClothingItemsDTO(5); + _mockRepo - .Setup(r => r.GetAllAsync(items.First().UserId)) + .Setup(r => r.GetAllAsync(DefaultUserId)) .ReturnsAsync(items); + + _mockMapper + .Setup(m => m.Map>(items)) + .Returns(itemsDto); // Act var result = await _serviceToTest.GetAllClothingAsync(items.First().UserId); // Assert _mockRepo.Verify(r => r.GetAllAsync(items.First().UserId), Times.Once); - result.Should().BeEquivalentTo(items); + result.Should().BeEquivalentTo(itemsDto); } [Test] @@ -84,14 +109,20 @@ public async Task GetAllClothingAsync_MultipleItemsExists_ReturnsAllItems() { // Arrange var items = CreateSampleClothingItems(5); + var itemsDto = CreateSampleClothingItemsDTO(5); + _mockRepo - .Setup(r => r.GetAllAsync(items.First().UserId)) + .Setup(r => r.GetAllAsync(DefaultUserId)) .ReturnsAsync(items); + + _mockMapper + .Setup(m => m.Map>(items)) + .Returns(itemsDto); // Act - var result = await _serviceToTest.GetAllClothingAsync(items.First().UserId); + var result = await _serviceToTest.GetAllClothingAsync(DefaultUserId); + // Assert - _mockRepo.Verify(r => r.GetAllAsync(items.First().UserId), Times.Once); - result.Should().BeEquivalentTo(items); + result.Should().BeEquivalentTo(itemsDto); } [Test] @@ -99,34 +130,147 @@ public async Task GetAllClothingAsync_NoClothingItemsExist_ReturnsEmptyList() { // Arrange var items = new List(); - _mockRepo - .Setup(r => r.GetAllAsync(DefaultUserId)) - .ReturnsAsync(items); + var itemsDto = new List(); + + _mockRepo.Setup(r => r.GetAllAsync(DefaultUserId)).ReturnsAsync(items); + _mockMapper.Setup(m => m.Map>(items)).Returns(itemsDto); + // Act var result = await _serviceToTest.GetAllClothingAsync(DefaultUserId); - // Assert - _mockRepo.Verify(r => r.GetAllAsync(DefaultUserId), Times.Once); + // Assert using (new AssertionScope()) { - result.Should().BeEquivalentTo(items); + result.Should().BeEquivalentTo(itemsDto); result.Should().BeEmpty(); } } #endregion + #region AddNewClothingItem + + [Test] + public async Task AddNewClothingItem_InvalidImageBase64_DoesNotCreateImageFile() + { + // Arrange + var clothingItem = CreateSampleClothingItem(); + var newClothingItem = CreateSampleNewClothingItem(); + _mockMapper.Setup(m => m.Map(newClothingItem)).Returns(clothingItem); + _mockMiscMethods + .Setup(x => x.IsValidBase64(newClothingItem.ImageBase64)) + .Returns(false); + // Act + await _serviceToTest.AddNewClothingItem(clothingItem.UserId, newClothingItem); + // Assert + _mockMapper.Verify(x => x.Map(newClothingItem), Times.Once); + _mockMiscMethods.Verify(x => x.IsValidBase64(newClothingItem.ImageBase64), Times.Once); + _mockFileService.Verify( + x => x.SaveImage(It.Is(g => g != Guid.Empty), It.IsAny()), + Times.Never); + _mockRepo.Verify(x => x.CreateAsync(It.Is(ci => + ci.UserId == clothingItem.UserId && + ci.ImageGuid == null + )), Times.Once); + _mockRepo.Verify(x => x.SaveAsync(), Times.Once); + } + + [Test] + public async Task AddNewClothingItem_ValidImageBase64_CreatesImageFile() + { + // Arrange + var clothingItem = CreateSampleClothingItem(); + var newClothingItem = CreateSampleNewClothingItem(ValidBase64); + _mockMapper.Setup(m => m.Map(newClothingItem)).Returns(clothingItem); + _mockMiscMethods + .Setup(x => x.IsValidBase64(newClothingItem.ImageBase64)) + .Returns(true); + // Act + await _serviceToTest.AddNewClothingItem(clothingItem.UserId, newClothingItem); + // Assert + _mockMapper.Verify(x => x.Map(newClothingItem), Times.Once); + _mockMiscMethods.Verify(x => x.IsValidBase64(newClothingItem.ImageBase64), Times.Once); + _mockFileService.Verify( + x => x.SaveImage(It.Is(g => g != Guid.Empty), It.Is(s => s == ValidBase64)), + Times.Once); + _mockRepo.Verify(x => x.CreateAsync(It.Is(ci => + ci.UserId == clothingItem.UserId && + ci.ImageGuid != null + )), Times.Once); + _mockRepo.Verify(x => x.SaveAsync(), Times.Once); + } + + #endregion + + #region RemoveClothingItem + + [Test] + public async Task RemoveClothingItem_ItemDoesNotExist_ErrorLogged() + { + // Arrange + var clothingItem = CreateSampleClothingItem(DefaultUserId, DefaultItemId); + _mockRepo + .Setup(r => r.GetAsync(clothingItem.UserId, clothingItem.Id)) + .ReturnsAsync(null as ClothingItem); + // Act + await _serviceToTest.DeleteClothingItem(clothingItem.UserId, clothingItem.Id); + // Assert + var latestLog = _fakeLogger.Collector.LatestRecord; + latestLog.Should().NotBeNull(); + latestLog.Level.Should().Be(LogLevel.Information); + latestLog.Message.Should().Contain("not found"); // or whatever your message is + } + + [Test] + public async Task RemoveClothingItem_ItemExistsAndImageGuidNull_FileNotDeleted() + { + // Arrange + var clothingItem = CreateSampleClothingItem(DefaultUserId, DefaultItemId); + _mockRepo + .Setup(r => r.GetAsync(DefaultUserId, DefaultItemId)) + .ReturnsAsync(clothingItem); + // Act + await _serviceToTest.DeleteClothingItem(DefaultUserId, DefaultItemId); + // Assert + _mockRepo.Verify(x => x.Remove(clothingItem), Times.Once); + _mockRepo.Verify(x => x.SaveAsync(), Times.Once); + _mockFileService.Verify(x => x.DeleteImage(It.Is(s => s == clothingItem.ImageGuid)), Times.Never); + } + + [Test] + public async Task RemoveClothingItem_ItemExistsAndImageGuidExists_FileDeleted() + { + // Arrange + var clothingItem = CreateSampleClothingItem(DefaultUserId, DefaultItemId); + clothingItem.ImageGuid = Guid.NewGuid(); + _mockRepo + .Setup(r => r.GetAsync(DefaultUserId, DefaultItemId)) + .ReturnsAsync(clothingItem); + // Act + await _serviceToTest.DeleteClothingItem(DefaultUserId, DefaultItemId); + // Assert + _mockRepo.Verify(x => x.Remove(clothingItem), Times.Once); + _mockRepo.Verify(x => x.SaveAsync(), Times.Once); + _mockFileService.Verify(x => x.DeleteImage(It.Is(s => s == clothingItem.ImageGuid)), Times.Once); + } + + #endregion + #region Private methods - private ClothingItem CreateSampleClothingItem() + private ClothingItem CreateSampleClothingItem(Guid? imageGuid = null) { - var clothingItem = new ClothingItem("T-shirt", ClothingCategory.TShirt, Season.None, - WearLocation.HomeAndOutside, false, 0, Guid.NewGuid()) + var clothingItem = new ClothingItem { + Name = "test", + Category = ClothingCategory.None, + Season = Season.None, + Size = ClothingSize.NotSpecified, + WearLocation = WearLocation.None, + ImageGuid = imageGuid, UserId = DefaultUserId, Id = DefaultItemId }; - return clothingItem; } @@ -150,5 +294,61 @@ private List CreateSampleClothingItems(int amount, string userId = return items; } + private ClothingItemDTO CreateSampleClothingItemDTO(Guid? imageGuid = null) + { + var clothingItem = new ClothingItemDTO + { + Name = "test", + Category = ClothingCategory.None, + Size = ClothingSize.NotSpecified, + ImageGuid = imageGuid, + Id = DefaultItemId + }; + return clothingItem; + } + + private ClothingItemDTO CreateSampleClothingItemDTO(int itemId) + { + var item = CreateSampleClothingItemDTO(); + item.Id = itemId; + return item; + } + + private List CreateSampleClothingItemsDTO(int amount, string userId = DefaultUserId) + { + List items = []; + var currentItemId = DefaultItemId; + for (int i = 0; i < amount; i++) + { + items.Add(CreateSampleClothingItemDTO(currentItemId++)); + } + + return items; + } + + + private NewClothingItemDTO CreateSampleNewClothingItem(string? imageBase64 = null) + { + var item = new NewClothingItemDTO + { + Name = "test", + Category = ClothingCategory.None, + ImageBase64 = imageBase64, + Size = ClothingSize.NotSpecified + }; + return item; + } + + private List CreateSampleNewClothingItems(int amount) + { + List items = []; + for (int i = 0; i < amount; i++) + { + items.Add(CreateSampleNewClothingItem()); + } + + return items; + } + #endregion } \ No newline at end of file diff --git a/WardrobeManager.Api/Database/Entities/ClothingItem.cs b/WardrobeManager.Api/Database/Entities/ClothingItem.cs index bc3c69a..cabb425 100644 --- a/WardrobeManager.Api/Database/Entities/ClothingItem.cs +++ b/WardrobeManager.Api/Database/Entities/ClothingItem.cs @@ -9,11 +9,9 @@ namespace WardrobeManager.Api.Database.Entities; public class ClothingItem : IDatabaseEntity { -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public ClothingItem() { } // ONLY FOR DESERIALIZER, DO NOT USE THIS. THIS SHIT BETTER HAVE NO REFERENCES -#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. // deserializer needs a way to create the object without any fields so it can assign them if they exist @@ -50,6 +48,7 @@ public ClothingItem public string Name { get; set; } public ClothingCategory Category { get; set; } = ClothingCategory.None; public Season Season { get; set; } = Season.None; + public ClothingSize Size { get; set; } = ClothingSize.NotSpecified; public WearLocation WearLocation { get; set; } = WearLocation.None; diff --git a/WardrobeManager.Api/Endpoints/ClothingEndpoints.cs b/WardrobeManager.Api/Endpoints/ClothingEndpoints.cs index 738562d..a61febe 100644 --- a/WardrobeManager.Api/Endpoints/ClothingEndpoints.cs +++ b/WardrobeManager.Api/Endpoints/ClothingEndpoints.cs @@ -7,25 +7,33 @@ using WardrobeManager.Api.Services.Implementation; using WardrobeManager.Api.Services.Interfaces; using WardrobeManager.Shared.Models; +using AutoMapper; +using WardrobeManager.Shared.DTOs; +using WardrobeManager.Shared.StaticResources; #endregion namespace WardrobeManager.Api.Endpoints; -public static class ClothingEndpoints { - public static void MapClothingEndpoints(this IEndpointRouteBuilder app) { +public static class ClothingEndpoints +{ + public static void MapClothingEndpoints(this IEndpointRouteBuilder app) + { var group = app.MapGroup("/clothing").RequireAuthorization(); - group.MapGet("", GetClothing); + group.MapGet("", GetClothingAsync); + group.MapPost("/add", AddNewClothingItemAsync); + group.MapPost("/delete", DeleteClothingItemAsync); // maybe should get a GET request, idc rn } // --------------------- // Get all clothing items // --------------------- - public static async Task GetClothing( - HttpContext context, IClothingService clothingService, DatabaseContext _context - ){ + public static async Task GetClothingAsync( + HttpContext context, IClothingService clothingService + ) + { User? user = context.Items["user"] as User; Debug.Assert(user != null, "Cannot get user"); @@ -34,4 +42,34 @@ public static async Task GetClothing( return Results.Ok(clothes); } -} + public static async Task AddNewClothingItemAsync( + [FromBody] NewClothingItemDTO newClothingItem, + HttpContext context, IClothingService clothingService, IMapper mapper + ) + { + User? user = context.Items["user"] as User; + Debug.Assert(user != null, "Cannot get user"); + + var res = StaticValidators.Validate(newClothingItem); + if (!res.Success) + { + return Results.BadRequest(res.Message); + } + await clothingService.AddNewClothingItem(user.Id ,newClothingItem); + + return Results.Ok(); + } + + public static async Task DeleteClothingItemAsync( + [FromBody] int itemId, + HttpContext context, IClothingService clothingService, IMapper mapper + ) + { + User? user = context.Items["user"] as User; + Debug.Assert(user != null, "Cannot get user"); + + await clothingService.DeleteClothingItem(user.Id ,itemId); + + return Results.Ok(); + } +} \ No newline at end of file diff --git a/WardrobeManager.Api/Endpoints/UserEndpoints.cs b/WardrobeManager.Api/Endpoints/UserEndpoints.cs index c1d7ded..8466a70 100644 --- a/WardrobeManager.Api/Endpoints/UserEndpoints.cs +++ b/WardrobeManager.Api/Endpoints/UserEndpoints.cs @@ -5,6 +5,7 @@ using WardrobeManager.Api.Database.Entities; using WardrobeManager.Api.Services.Implementation; using WardrobeManager.Api.Services.Interfaces; +using WardrobeManager.Shared.DTOs; using WardrobeManager.Shared.Models; #endregion diff --git a/WardrobeManager.Api/Middleware/UserCreationMiddleware.cs b/WardrobeManager.Api/Middleware/UserCreationMiddleware.cs index 4fa51f4..04a40da 100644 --- a/WardrobeManager.Api/Middleware/UserCreationMiddleware.cs +++ b/WardrobeManager.Api/Middleware/UserCreationMiddleware.cs @@ -23,28 +23,15 @@ public UserCreationMiddleware(RequestDelegate next) public async Task InvokeAsync(HttpContext context) { - - // var userService = context.RequestServices.GetRequiredService(); - - // -------------- !!IMPORTANT!! ------------- - // Previously I was using Auth0 as an authentication provider. - // Coincidentally, Microsoft Identity and Auto0 used the NameIdentifier claim for a user - // So all the old code still works 100% fine. - // For this reason I will not be modifying anything Auth0 at the moment - // var Auth0Id = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - - // only create the user if the consumer of the api is authorized - // if (Auth0Id != null) - // { - // await userService.CreateUser(Auth0Id); - // Debug.Assert(await userService.DoesUserExist(Auth0Id) == true, "User finding/creating is broken"); - // - // var user = await userService.GetUser(Auth0Id); - // Debug.Assert(user != null, "At this point in the pipeline user should exist"); - // - // // pass along to controllers - // context.Items["user"] = user; - // } + var userService = context.RequestServices.GetRequiredService(); + var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; + + var user = await userService.GetUser(userId); + if (user != null) + { + // pass along to controllers + context.Items["user"] = user; + } await _next(context); } diff --git a/WardrobeManager.Api/Migrations/20260227172446_AddSizeToClothingItem.Designer.cs b/WardrobeManager.Api/Migrations/20260227172446_AddSizeToClothingItem.Designer.cs new file mode 100644 index 0000000..1df3466 --- /dev/null +++ b/WardrobeManager.Api/Migrations/20260227172446_AddSizeToClothingItem.Designer.cs @@ -0,0 +1,391 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using WardrobeManager.Api.Database; + +#nullable disable + +namespace WardrobeManager.Api.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260227172446_AddSizeToClothingItem")] + partial class AddSizeToClothingItem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("WardrobeManager.Api.Database.Entities.ClothingItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("DesiredTimesWornBeforeWash") + .HasColumnType("INTEGER"); + + b.Property("Favourited") + .HasColumnType("INTEGER"); + + b.Property("ImageGuid") + .HasColumnType("TEXT"); + + b.Property("LastWorn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Season") + .HasColumnType("INTEGER"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("TimesWornSinceWash") + .HasColumnType("INTEGER"); + + b.Property("TimesWornTotal") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WearLocation") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ClothingItems"); + }); + + modelBuilder.Entity("WardrobeManager.Api.Database.Entities.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Origin") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Logs"); + }); + + modelBuilder.Entity("WardrobeManager.Api.Database.Entities.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("ProfilePictureBase64") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.HasOne("WardrobeManager.Api.Database.Entities.User", null) + .WithMany("Roles") + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("WardrobeManager.Api.Database.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("WardrobeManager.Api.Database.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("WardrobeManager.Api.Database.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("WardrobeManager.Api.Database.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("WardrobeManager.Api.Database.Entities.ClothingItem", b => + { + b.HasOne("WardrobeManager.Api.Database.Entities.User", "User") + .WithMany("ServerClothingItems") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WardrobeManager.Api.Database.Entities.User", b => + { + b.Navigation("Roles"); + + b.Navigation("ServerClothingItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/WardrobeManager.Api/Migrations/20260227172446_AddSizeToClothingItem.cs b/WardrobeManager.Api/Migrations/20260227172446_AddSizeToClothingItem.cs new file mode 100644 index 0000000..240826d --- /dev/null +++ b/WardrobeManager.Api/Migrations/20260227172446_AddSizeToClothingItem.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WardrobeManager.Api.Migrations +{ + /// + public partial class AddSizeToClothingItem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Size", + table: "ClothingItems", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Size", + table: "ClothingItems"); + } + } +} diff --git a/WardrobeManager.Api/Migrations/DatabaseContextModelSnapshot.cs b/WardrobeManager.Api/Migrations/DatabaseContextModelSnapshot.cs index 04ed636..e2e89a2 100644 --- a/WardrobeManager.Api/Migrations/DatabaseContextModelSnapshot.cs +++ b/WardrobeManager.Api/Migrations/DatabaseContextModelSnapshot.cs @@ -150,35 +150,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserTokens", (string)null); }); - modelBuilder.Entity("WardrobeManager.Api.Database.Entities.Log", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Created") - .HasColumnType("TEXT"); - - b.Property("Description") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Origin") - .HasColumnType("INTEGER"); - - b.Property("Title") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.ToTable("Logs"); - }); - - modelBuilder.Entity("WardrobeManager.Api.Database.Entities.ServerClothingItem", b => + modelBuilder.Entity("WardrobeManager.Api.Database.Entities.ClothingItem", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -212,6 +184,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Season") .HasColumnType("INTEGER"); + b.Property("Size") + .HasColumnType("INTEGER"); + b.Property("TimesWornSinceWash") .HasColumnType("INTEGER"); @@ -232,6 +207,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ClothingItems"); }); + modelBuilder.Entity("WardrobeManager.Api.Database.Entities.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Origin") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Logs"); + }); + modelBuilder.Entity("WardrobeManager.Api.Database.Entities.User", b => { b.Property("Id") @@ -362,7 +365,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("WardrobeManager.Api.Database.Entities.ServerClothingItem", b => + modelBuilder.Entity("WardrobeManager.Api.Database.Entities.ClothingItem", b => { b.HasOne("WardrobeManager.Api.Database.Entities.User", "User") .WithMany("ServerClothingItems") diff --git a/WardrobeManager.Api/Program.cs b/WardrobeManager.Api/Program.cs index c50342a..936fed3 100644 --- a/WardrobeManager.Api/Program.cs +++ b/WardrobeManager.Api/Program.cs @@ -13,6 +13,7 @@ using WardrobeManager.Api.Services.Implementation; using WardrobeManager.Api.Services.Interfaces; using WardrobeManager.Shared.DTOs; +using WardrobeManager.Shared.StaticResources; #endregion @@ -86,11 +87,14 @@ // Add your maps here directly cfg.CreateMap().ReverseMap(); + cfg.CreateMap(); + cfg.CreateMap().ReverseMap(); }); // Entity Services builder.Services.AddScoped, GenericRepository>(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // add db context builder.Services.AddDbContext((serviceProvider, options) => diff --git a/WardrobeManager.Api/Repositories/Implementation/ClothingRepository.cs b/WardrobeManager.Api/Repositories/Implementation/ClothingRepository.cs index 0f07dcd..6033c78 100644 --- a/WardrobeManager.Api/Repositories/Implementation/ClothingRepository.cs +++ b/WardrobeManager.Api/Repositories/Implementation/ClothingRepository.cs @@ -5,12 +5,8 @@ namespace WardrobeManager.Api.Repositories.Implementation; -public class ClothingRepository: GenericRepository, IClothingRepository +public class ClothingRepository(DatabaseContext context) : GenericRepository(context), IClothingRepository { - public ClothingRepository(DatabaseContext context, DbSet clothingItems) : base(context) - { - } - public async Task GetAsync(string userId, int itemId) { return await _dbSet.FirstOrDefaultAsync(item => item.Id == itemId && item.UserId == userId); diff --git a/WardrobeManager.Api/Services/Implementation/ClothingService.cs b/WardrobeManager.Api/Services/Implementation/ClothingService.cs index 4ff53a8..153d2df 100644 --- a/WardrobeManager.Api/Services/Implementation/ClothingService.cs +++ b/WardrobeManager.Api/Services/Implementation/ClothingService.cs @@ -1,11 +1,14 @@ #region +using AutoMapper; using Microsoft.EntityFrameworkCore; +using SQLitePCL; using WardrobeManager.Api.Database; using WardrobeManager.Api.Database.Entities; using WardrobeManager.Api.Repositories; using WardrobeManager.Api.Repositories.Interfaces; using WardrobeManager.Api.Services.Interfaces; +using WardrobeManager.Shared.DTOs; using WardrobeManager.Shared.Enums; using WardrobeManager.Shared.Models; using WardrobeManager.Shared.StaticResources; @@ -14,22 +17,61 @@ namespace WardrobeManager.Api.Services.Implementation; -public class ClothingService(IClothingRepository clothingRepository) +public class ClothingService( + IClothingRepository clothingRepository, + IMapper mapper, + IFileService fileService, + ILogger logger, + IMiscMethods miscMethods +) : IClothingService { - - // ---- Methods for multiple clothing items --- - public async Task?> GetAllClothingAsync(string userId) + public async Task?> GetAllClothingAsync(string userId) { - return await clothingRepository.GetAllAsync(userId); + var res = await clothingRepository.GetAllAsync(userId); + return mapper.Map>(res); } // ---- Methods for one clothing item --- - public async Task GetClothingItemAsync(string userId, int itemId) + public async Task GetClothingItemAsync(string userId, int itemId) { - return await clothingRepository.GetAsync(userId, itemId); + var res = await clothingRepository.GetAsync(userId, itemId); + return mapper.Map(res); } + public async Task AddNewClothingItem(string userId, NewClothingItemDTO newClothingItem) + { + var res = mapper.Map(newClothingItem); + Guid? newItemGuid = null; + if (miscMethods.IsValidBase64(newClothingItem.ImageBase64)) + { + newItemGuid = Guid.NewGuid(); + // decode and save file to place on disk with guid as name + await fileService.SaveImage(newItemGuid, newClothingItem.ImageBase64!); + } -} + res.UserId = userId; + res.ImageGuid = newItemGuid; + await clothingRepository.CreateAsync(res); + await clothingRepository.SaveAsync(); + } + + public async Task DeleteClothingItem(string userId, int itemId) + { + var res = await clothingRepository.GetAsync(userId, itemId); + if (res != null) + { + clothingRepository.Remove(res); + await clothingRepository.SaveAsync(); + if (res.ImageGuid != null) + { + await fileService.DeleteImage((Guid)res.ImageGuid); + } + } + else + { + logger.LogInformation($"Clothing item {itemId} not found"); + } + } +} \ No newline at end of file diff --git a/WardrobeManager.Api/Services/Implementation/DataDirectoryService.cs b/WardrobeManager.Api/Services/Implementation/DataDirectoryService.cs index 761ff41..5308cb3 100644 --- a/WardrobeManager.Api/Services/Implementation/DataDirectoryService.cs +++ b/WardrobeManager.Api/Services/Implementation/DataDirectoryService.cs @@ -44,4 +44,10 @@ public string GetUploadsDirectory() Directory.CreateDirectory(path); return path; } + public string GetDeletedUploadsDirectory() + { + var path = Path.Combine(GetImagesDirectory(), "deleted"); + Directory.CreateDirectory(path); + return path; + } } \ No newline at end of file diff --git a/WardrobeManager.Api/Services/Implementation/FileService.cs b/WardrobeManager.Api/Services/Implementation/FileService.cs index b685b0d..91b3096 100644 --- a/WardrobeManager.Api/Services/Implementation/FileService.cs +++ b/WardrobeManager.Api/Services/Implementation/FileService.cs @@ -1,5 +1,6 @@ #region +using SQLitePCL; using WardrobeManager.Api.Services.Interfaces; #endregion @@ -9,6 +10,7 @@ namespace WardrobeManager.Api.Services.Implementation; public class FileService( IDataDirectoryService dataDirectoryService, IWebHostEnvironment webHostEnvironment, + ILogger logger, IConfiguration configuration) : IFileService { @@ -31,14 +33,14 @@ public async Task SaveImage(Guid? guid, string ImageBase64) byte[] imageBytes = Convert.FromBase64String(ImageBase64); // 5MB default max file size - var max_file_size = configuration["WM_MAX_IMAGE_UPLOAD_SIZE_IN_MB"] ?? "5"; - int max_file_size_num = Convert.ToInt32(max_file_size); - max_file_size_num *= 1024; + var maxFileSize = configuration["WM_MAX_IMAGE_UPLOAD_SIZE_IN_MB"] ?? "5"; + int maxFileSizeNum = Convert.ToInt32(maxFileSize); + maxFileSizeNum *= 1024 * 1024; - if (imageBytes.Length > max_file_size_num) + if (imageBytes.Length > maxFileSizeNum) { - throw new Exception( - $"File size too large! Received file size: {imageBytes.Length / 1024} MB. Max file size: {max_file_size_num / 1024} MB"); + throw new ArgumentOutOfRangeException( + $"File size too large! Received file size: {imageBytes.Length / 1024} MB. Max file size: {maxFileSizeNum / 1024} MB"); } string path = Path.Combine(dataDirectoryService.GetUploadsDirectory(), ParseGuid(properGuid)); @@ -63,4 +65,20 @@ public async Task GetImage(string guid) return imageBytes; } } + + public async Task DeleteImage(Guid givenGuid) + { + var guid = ParseGuid(givenGuid); + string path = Path.Combine(dataDirectoryService.GetUploadsDirectory(), guid); + string deletePath = Path.Combine(dataDirectoryService.GetDeletedUploadsDirectory(), guid); + + // Move deleted images to deleted folder (groundwork for "restore deleted items" feature) + if (File.Exists(path)) + { + File.Move(path, deletePath); + return; + } + + logger.LogError($"Could not delete image {guid} as it does not exist"); + } } \ No newline at end of file diff --git a/WardrobeManager.Api/Services/Implementation/UserService.cs b/WardrobeManager.Api/Services/Implementation/UserService.cs index 63bce2a..42ee7ae 100644 --- a/WardrobeManager.Api/Services/Implementation/UserService.cs +++ b/WardrobeManager.Api/Services/Implementation/UserService.cs @@ -8,6 +8,7 @@ using WardrobeManager.Api.Database.Entities; using WardrobeManager.Api.Repositories; using WardrobeManager.Api.Services.Interfaces; +using WardrobeManager.Shared.DTOs; using WardrobeManager.Shared.Enums; using WardrobeManager.Shared.Models; diff --git a/WardrobeManager.Api/Services/Interfaces/IClothingService.cs b/WardrobeManager.Api/Services/Interfaces/IClothingService.cs index 9182ebc..4be74be 100644 --- a/WardrobeManager.Api/Services/Interfaces/IClothingService.cs +++ b/WardrobeManager.Api/Services/Interfaces/IClothingService.cs @@ -1,6 +1,7 @@ #region using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Shared.DTOs; using WardrobeManager.Shared.Enums; using WardrobeManager.Shared.Models; @@ -9,7 +10,9 @@ namespace WardrobeManager.Api.Services.Interfaces; public interface IClothingService { - Task?> GetAllClothingAsync(string userId); + Task?> GetAllClothingAsync(string userId); - Task GetClothingItemAsync(string userId, int itemId); + Task GetClothingItemAsync(string userId, int itemId); + Task AddNewClothingItem(string userId, NewClothingItemDTO newNewClothingItem); + Task DeleteClothingItem(string userId, int itemId); } diff --git a/WardrobeManager.Api/Services/Interfaces/IDataDirectoryService.cs b/WardrobeManager.Api/Services/Interfaces/IDataDirectoryService.cs index 047cb9f..9fed5fe 100644 --- a/WardrobeManager.Api/Services/Interfaces/IDataDirectoryService.cs +++ b/WardrobeManager.Api/Services/Interfaces/IDataDirectoryService.cs @@ -6,4 +6,5 @@ public interface IDataDirectoryService public string GetDatabaseDirectory(); public string GetImagesDirectory(); public string GetUploadsDirectory(); + public string GetDeletedUploadsDirectory(); } diff --git a/WardrobeManager.Api/Services/Interfaces/IFileService.cs b/WardrobeManager.Api/Services/Interfaces/IFileService.cs index 91ba561..b776a4a 100644 --- a/WardrobeManager.Api/Services/Interfaces/IFileService.cs +++ b/WardrobeManager.Api/Services/Interfaces/IFileService.cs @@ -7,5 +7,6 @@ public interface IFileService /// byte array of image Task GetImage(string guid); string ParseGuid(Guid guid); - Task SaveImage(Guid? guid, string ImageBase64); + Task SaveImage(Guid? guid, string imageBase64); + Task DeleteImage(Guid givenGuid); } diff --git a/WardrobeManager.Api/Services/Interfaces/IUserService.cs b/WardrobeManager.Api/Services/Interfaces/IUserService.cs index 1de68d7..7aba802 100644 --- a/WardrobeManager.Api/Services/Interfaces/IUserService.cs +++ b/WardrobeManager.Api/Services/Interfaces/IUserService.cs @@ -1,4 +1,5 @@ using WardrobeManager.Api.Database.Entities; +using WardrobeManager.Shared.DTOs; using WardrobeManager.Shared.Models; namespace WardrobeManager.Api.Services.Interfaces; diff --git a/WardrobeManager.Api/WardrobeManager.Api.csproj b/WardrobeManager.Api/WardrobeManager.Api.csproj index 135b6d2..54482d8 100644 --- a/WardrobeManager.Api/WardrobeManager.Api.csproj +++ b/WardrobeManager.Api/WardrobeManager.Api.csproj @@ -1,38 +1,41 @@ - - net10.0 - enable - enable - 0c85772c-6d6d-44fd-b892-8607c38600f6 - Linux - + + net10.0 + enable + enable + 0c85772c-6d6d-44fd-b892-8607c38600f6 + Linux + + + CS8618 + - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + - - - + + + - - - - - + + + + + diff --git a/WardrobeManager.Presentation/Components/Identity/NotAuthenticated.razor b/WardrobeManager.Presentation/Components/Identity/NotAuthenticated.razor index 908331d..2b51e03 100644 --- a/WardrobeManager.Presentation/Components/Identity/NotAuthenticated.razor +++ b/WardrobeManager.Presentation/Components/Identity/NotAuthenticated.razor @@ -1,7 +1,7 @@ @namespace WardrobeManager.Presentation.Components.Identity -
-

You are not authenticated

-

Please log in here

+
+

You are not authenticated

+

Please log in here

diff --git a/WardrobeManager.Presentation/Components/Shared/Image.razor b/WardrobeManager.Presentation/Components/Shared/Image.razor index 337f93b..b2ba61b 100644 --- a/WardrobeManager.Presentation/Components/Shared/Image.razor +++ b/WardrobeManager.Presentation/Components/Shared/Image.razor @@ -1,9 +1,9 @@ @namespace WardrobeManager.Presentation.Components.Shared - + @code { - [Parameter] public required Guid? ImageGuid { get; set; } + [Parameter] public required string? ImageGuid { get; set; } [Parameter] public required string Css { get; set; } string _imagePath = string.Empty; @@ -13,7 +13,7 @@ // the way my api controller matches routes you need to supply a path for /images/{image_id} // because some clothing items can have no image the Guid with therefore be null and /images/ will be called which doesn't trigger the api // suppress nullable error because it doesn't realize i'm already checking it - _imagePath = (string.IsNullOrEmpty(ImageGuid.ToString()) ? "fakepath" : ImageGuid.ToString())!; + _imagePath = (string.IsNullOrEmpty(ImageGuid) ? "fakepath" : ImageGuid); await base.OnParametersSetAsync(); } diff --git a/WardrobeManager.Presentation/Components/Shared/Notifications.razor b/WardrobeManager.Presentation/Components/Shared/Notifications.razor index 9f7257c..0b528f5 100644 --- a/WardrobeManager.Presentation/Components/Shared/Notifications.razor +++ b/WardrobeManager.Presentation/Components/Shared/Notifications.razor @@ -8,7 +8,7 @@ { var cssClass = GetNotificationButtonType(notification.Type); - + @notification.Message } diff --git a/WardrobeManager.Presentation/CustomHttpMessageHandler.cs b/WardrobeManager.Presentation/CustomHttpMessageHandler.cs new file mode 100644 index 0000000..e52288a --- /dev/null +++ b/WardrobeManager.Presentation/CustomHttpMessageHandler.cs @@ -0,0 +1,49 @@ +#region + +using Microsoft.AspNetCore.Components.WebAssembly.Http; +using WardrobeManager.Presentation.Services.Interfaces; +using WardrobeManager.Shared.Enums; + +#endregion + +namespace WardrobeManager.Presentation; + +/// +/// Handler to ensure cookie credentials are automatically sent over with each request. +/// +public class CustomHttpMessageHandler(INotificationService notificationService) : DelegatingHandler +{ + /// + /// Main method to override for the handler. + /// + /// The original request. + /// The token to handle cancellations. + /// The . + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // include cookies! + request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); + request.Headers.Add("X-Requested-With", ["XMLHttpRequest"]); + + try + { + // This sends the actual HTTP request + var response = await base.SendAsync(request, cancellationToken); + + // 1. Check for standard HTTP errors (like 404 Not Found, 500 Server Error) + if (!response.IsSuccessStatusCode) + { + notificationService.AddNotification($"Server Error: {(int)response.StatusCode}", NotificationType.Error); + } + + return response; + } + catch (HttpRequestException ex) // 2. Catch network failures (offline, CORS, server dead) + { + notificationService.AddNotification($"HttpRequestException: {ex.Message}", NotificationType.Error); + // rethrow so exception is not lost, we just want to log it here. + // If we don't rethrow code called after the http request will still run + throw; + } + } +} diff --git a/WardrobeManager.Presentation/Identity/CookieAuthenticationStateProvider.cs b/WardrobeManager.Presentation/Identity/CookieAuthenticationStateProvider.cs index 9082188..d8115bd 100644 --- a/WardrobeManager.Presentation/Identity/CookieAuthenticationStateProvider.cs +++ b/WardrobeManager.Presentation/Identity/CookieAuthenticationStateProvider.cs @@ -30,11 +30,6 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA /// private readonly HttpClient _httpClient; - /// - /// Authentication state. - /// - private bool _authenticated = false; - /// /// Default principal for anonymous (not authenticated) users. /// @@ -166,8 +161,6 @@ public async Task LoginAsync(string email, string password) /// The authentication state asynchronous request. public override async Task GetAuthenticationStateAsync() { - _authenticated = false; - // default to not authenticated var user = Unauthenticated; @@ -226,7 +219,6 @@ public override async Task GetAuthenticationStateAsync() // set the principal var id = new ClaimsIdentity(claims, nameof(CookieAuthenticationStateProvider)); user = new ClaimsPrincipal(id); - _authenticated = true; } } catch diff --git a/WardrobeManager.Presentation/Identity/CookieHandler.cs b/WardrobeManager.Presentation/Identity/CookieHandler.cs deleted file mode 100644 index cee1447..0000000 --- a/WardrobeManager.Presentation/Identity/CookieHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -#region - -using Microsoft.AspNetCore.Components.WebAssembly.Http; - -#endregion - -namespace WardrobeManager.Presentation.Identity; - -/// -/// Handler to ensure cookie credentials are automatically sent over with each request. -/// -public class CookieHandler : DelegatingHandler -{ - /// - /// Main method to override for the handler. - /// - /// The original request. - /// The token to handle cancellations. - /// The . - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - // include cookies! - request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); - request.Headers.Add("X-Requested-With", ["XMLHttpRequest"]); - - return base.SendAsync(request, cancellationToken); - - - } -} \ No newline at end of file diff --git a/WardrobeManager.Presentation/HelperClasses.cs b/WardrobeManager.Presentation/Identity/CustomAuthorizationMessageHandler.cs similarity index 91% rename from WardrobeManager.Presentation/HelperClasses.cs rename to WardrobeManager.Presentation/Identity/CustomAuthorizationMessageHandler.cs index 6a18c1e..d686ca2 100644 --- a/WardrobeManager.Presentation/HelperClasses.cs +++ b/WardrobeManager.Presentation/Identity/CustomAuthorizationMessageHandler.cs @@ -5,7 +5,7 @@ #endregion -namespace WardrobeManager.Presentation; +namespace WardrobeManager.Presentation.Identity; public class CustomAuthorizationMessageHandler : AuthorizationMessageHandler { @@ -17,4 +17,4 @@ public CustomAuthorizationMessageHandler(IAccessTokenProvider provider, authorizedUrls: new[] { "https://localhost:7026" }); //scopes: new[] { "example.read", "example.write" }); } -} \ No newline at end of file +} diff --git a/WardrobeManager.Presentation/Layout/NavMenu.razor b/WardrobeManager.Presentation/Layout/NavMenu.razor index 0debe40..2c7ed9b 100644 --- a/WardrobeManager.Presentation/Layout/NavMenu.razor +++ b/WardrobeManager.Presentation/Layout/NavMenu.razor @@ -49,54 +49,74 @@
-
+
diff --git a/WardrobeManager.Presentation/Pages/Authenticated/AddClothingItem.razor b/WardrobeManager.Presentation/Pages/Authenticated/AddClothingItem.razor index 3b9b749..3a12064 100644 --- a/WardrobeManager.Presentation/Pages/Authenticated/AddClothingItem.razor +++ b/WardrobeManager.Presentation/Pages/Authenticated/AddClothingItem.razor @@ -1,6 +1,59 @@ @page "/add" @using WardrobeManager.Presentation.Components.Clothing - +@using WardrobeManager.Presentation.Components.FormItems +@using WardrobeManager.Shared.StaticResources @namespace WardrobeManager.Presentation.Pages.Authenticated +@attribute [Authorize] +@inherits MvvmComponentBase + +
+

Add a clothing item

+
+ +
+
\ No newline at end of file diff --git a/WardrobeManager.Presentation/Pages/Authenticated/Clothing.razor b/WardrobeManager.Presentation/Pages/Authenticated/Clothing.razor deleted file mode 100644 index 4abcc4d..0000000 --- a/WardrobeManager.Presentation/Pages/Authenticated/Clothing.razor +++ /dev/null @@ -1,10 +0,0 @@ -@page "/clothing" -@attribute [Authorize] - - -@inject INotificationService _notificationService - -Clothing - -

Dashboard!

- diff --git a/WardrobeManager.Presentation/Pages/Authenticated/Wardrobe.razor b/WardrobeManager.Presentation/Pages/Authenticated/Wardrobe.razor new file mode 100644 index 0000000..f8738e1 --- /dev/null +++ b/WardrobeManager.Presentation/Pages/Authenticated/Wardrobe.razor @@ -0,0 +1,47 @@ +@page "/wardrobe" +@attribute [Authorize] +@using WardrobeManager.Shared.DTOs +@using ActionType = WardrobeManager.Presentation.ViewModels.ActionType +@inherits MvvmComponentBase + +Wardrobe + +
+

View your wardrobe here

+ + @if (ViewModel.ClothingItems != null) + { + + + + + + + + + + + + + + } + +
\ No newline at end of file diff --git a/WardrobeManager.Presentation/Pages/Public/Login.razor b/WardrobeManager.Presentation/Pages/Public/Login.razor index b6116d8..d4ce391 100644 --- a/WardrobeManager.Presentation/Pages/Public/Login.razor +++ b/WardrobeManager.Presentation/Pages/Public/Login.razor @@ -7,7 +7,7 @@

Log in

- ("#app"); builder.RootComponents.Add("head::after"); // Kind of like HttpClient middleware that adds a cookie to the request -builder.Services.AddTransient(); +builder.Services.AddTransient(); // Setup authorization builder.Services.AddAuthorizationCore(); @@ -42,12 +41,13 @@ // configure client for auth interactions builder.Services.AddHttpClient("Auth", opt => opt.BaseAddress = new Uri(BackendUrl)) - .AddHttpMessageHandler(); + .AddHttpMessageHandler(); // My Services builder.Services.AddScoped(sp => new ApiService(BackendUrl, sp.GetRequiredService())); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Libraries builder.Services.AddSysinfocus(); diff --git a/WardrobeManager.Presentation/Services/Implementation/ApiService.cs b/WardrobeManager.Presentation/Services/Implementation/ApiService.cs index b4057b9..2a90f5d 100644 --- a/WardrobeManager.Presentation/Services/Implementation/ApiService.cs +++ b/WardrobeManager.Presentation/Services/Implementation/ApiService.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using WardrobeManager.Presentation.Services.Interfaces; using WardrobeManager.Shared.DTOs; +using WardrobeManager.Shared.Enums; using WardrobeManager.Shared.Models; #endregion @@ -20,6 +21,29 @@ public ApiService(string apiEndpoint, IHttpClientFactory factory) _httpClient = factory.CreateClient("Auth"); } + #region Clothing + + public async Task> GetAllClothingItemsAsync() + { + return await _httpClient.GetFromJsonAsync>("/clothing") ?? []; + } + + public async Task AddNewClothingItemAsync(NewClothingItemDTO newNewClothingItem) + { + var response = await _httpClient.PostAsJsonAsync("/clothing/add", newNewClothingItem); + response.EnsureSuccessStatusCode(); + } + + public async Task DeleteClothingItemAsync(int itemId) + { + var response = await _httpClient.PostAsJsonAsync("/clothing/delete", itemId); + response.EnsureSuccessStatusCode(); + } + + #endregion + + #region Misc + public async Task CheckApiConnection() { // Probably not the best way since this would require wrapping each method in a try/catch but it works for now @@ -40,6 +64,10 @@ public async Task AddLog(LogDTO log) return con; } + #endregion + + #region Onboarding + public async Task DoesAdminUserExist() { var res = await _httpClient.GetAsync("/does-admin-user-exist"); @@ -64,6 +92,8 @@ public async Task DoesAdminUserExist() return (res.IsSuccessStatusCode, content); } + #endregion + public async ValueTask DisposeAsync() { _httpClient.Dispose(); diff --git a/WardrobeManager.Presentation/Services/Interfaces/IApiService.cs b/WardrobeManager.Presentation/Services/Interfaces/IApiService.cs index 20627bf..2573611 100644 --- a/WardrobeManager.Presentation/Services/Interfaces/IApiService.cs +++ b/WardrobeManager.Presentation/Services/Interfaces/IApiService.cs @@ -9,14 +9,23 @@ namespace WardrobeManager.Presentation.Services.Interfaces; public interface IApiService { ValueTask DisposeAsync(); - + + // Clothing + Task> GetAllClothingItemsAsync(); + Task AddNewClothingItemAsync(NewClothingItemDTO newNewClothingItem); + Task DeleteClothingItemAsync( int itemId); + // Misc Task CheckApiConnection(); Task AddLog(LogDTO log); - // User Management + // Onboarding Task DoesAdminUserExist(); - // bool: succeeded?, string: text description + /// + /// Creates an admin user if one doesn't exist (used for onboarding) + /// + /// Onboarding user credentials + /// bool: succeeded, string: text description Task<(bool, string)> CreateAdminUserIfMissing(AdminUserCredentials credentials); } diff --git a/WardrobeManager.Presentation/ViewModels/AddClothingItemViewModel.cs b/WardrobeManager.Presentation/ViewModels/AddClothingItemViewModel.cs new file mode 100644 index 0000000..2c5557e --- /dev/null +++ b/WardrobeManager.Presentation/ViewModels/AddClothingItemViewModel.cs @@ -0,0 +1,78 @@ +using Blazing.Mvvm.ComponentModel; +using Blazing.Mvvm.Components; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel.__Internals; +using Microsoft.AspNetCore.Components.Forms; +using WardrobeManager.Presentation.Services.Interfaces; +using WardrobeManager.Shared.DTOs; +using WardrobeManager.Shared.Enums; +using WardrobeManager.Shared.StaticResources; + +namespace WardrobeManager.Presentation.ViewModels; + +[ViewModelDefinition(Lifetime = ServiceLifetime.Scoped)] +public partial class AddClothingItemViewModel( + IApiService apiService, + INotificationService notificationService, + IMiscMethods miscMethods, + IConfiguration configuration +) + : ViewModelBase +{ + // Public Properties + [ObservableProperty] private NewClothingItemDTO _newClothingItem = new(); + + public string GetNameWithSpacesAndEmoji(T value) where T : Enum + => miscMethods.GetNameWithSpacesFromEnum(value); + + public ICollection ClothingCategories { get; set; } = + miscMethods.ConvertEnumToCollection(); + + public ICollection ClothingSizes { get; set; } = miscMethods.ConvertEnumToCollection(); + + public async Task SubmitAsync() + { + // Crude error checking, in the future i'd prefer to use a form with error validation + var res = StaticValidators.Validate(NewClothingItem); + if (!res.Success) + { + notificationService.AddNotification(res.Message, NotificationType.Error); + return; + } + + await apiService.AddNewClothingItemAsync(NewClothingItem); + notificationService.AddNotification($"Clothing Item \"{NewClothingItem.Name}\" Added!", + NotificationType.Success); + NewClothingItem = new NewClothingItemDTO(); + } + + public async Task UploadImage(InputFileChangeEventArgs e) + { + try + { + var img = new MemoryStream(); + + // 5MB default max file size + var maxFileSize = configuration["WM_MAX_IMAGE_UPLOAD_SIZE_IN_MB"]; + int maxFileSizeNum = maxFileSize == null + ? ProjectConstants.MaxImageSizeInMBFallback + : Convert.ToInt32(maxFileSize); + + maxFileSizeNum *= 1024 * 1024; // int to megabytes + + await e.File.OpenReadStream(maxAllowedSize: maxFileSizeNum).CopyToAsync(img); + + NewClothingItem.ImageBase64 = Convert.ToBase64String(img.ToArray()); + + if (NewClothingItem.ImageBase64 == string.Empty) + { + // If the image is too large it become an empty string. This is an edgecase but I can't remember how to reproduce it. + notificationService.AddNotification("Image size too large, try again.", NotificationType.Warning); + } + } + catch (IOException ex) + { + notificationService.AddNotification($"Error uploading image: {ex.Message}", NotificationType.Error); + } + } +} \ No newline at end of file diff --git a/WardrobeManager.Presentation/ViewModels/DashboardViewModel.cs b/WardrobeManager.Presentation/ViewModels/DashboardViewModel.cs index e5a7247..7b7c158 100644 --- a/WardrobeManager.Presentation/ViewModels/DashboardViewModel.cs +++ b/WardrobeManager.Presentation/ViewModels/DashboardViewModel.cs @@ -14,11 +14,6 @@ namespace WardrobeManager.Presentation.ViewModels; [ViewModelDefinition(Lifetime = ServiceLifetime.Scoped)] public partial class DashboardViewModel( - INotificationService notificationService, - NavigationManager navigationManager, - IMvvmNavigationManager navManager, - - IIdentityService identityService ) : ViewModelBase { diff --git a/WardrobeManager.Presentation/ViewModels/HomeViewModel.cs b/WardrobeManager.Presentation/ViewModels/HomeViewModel.cs index 6995e53..3a063b0 100644 --- a/WardrobeManager.Presentation/ViewModels/HomeViewModel.cs +++ b/WardrobeManager.Presentation/ViewModels/HomeViewModel.cs @@ -14,11 +14,6 @@ namespace WardrobeManager.Presentation.ViewModels; [ViewModelDefinition(Lifetime = ServiceLifetime.Scoped)] public partial class HomeViewModel( - INotificationService notificationService, - NavigationManager navigationManager, - IMvvmNavigationManager navManager, - - IIdentityService identityService ) : ViewModelBase { diff --git a/WardrobeManager.Presentation/ViewModels/LoginViewModel.cs b/WardrobeManager.Presentation/ViewModels/LoginViewModel.cs index 1f6f2c8..3e37795 100644 --- a/WardrobeManager.Presentation/ViewModels/LoginViewModel.cs +++ b/WardrobeManager.Presentation/ViewModels/LoginViewModel.cs @@ -12,8 +12,6 @@ namespace WardrobeManager.Presentation.ViewModels; [ViewModelDefinition(Lifetime = ServiceLifetime.Scoped)] public partial class LoginViewModel( - IAccountManagement accountManagement, - INotificationService notificationService, IMvvmNavigationManager navManager, IIdentityService identityService ) @@ -23,8 +21,6 @@ IIdentityService identityService [ObservableProperty] private AuthenticationCredentialsModel _authenticationCredentialsModel = new(); - public EditForm? EditForm { get; set; } - // Public Methods // Stupid that i'm doing this but its the easiest solution and idk what the best method is public void SetEmail(string email) diff --git a/WardrobeManager.Presentation/ViewModels/NavMenuViewModel.cs b/WardrobeManager.Presentation/ViewModels/NavMenuViewModel.cs index 9a4e690..abcc186 100644 --- a/WardrobeManager.Presentation/ViewModels/NavMenuViewModel.cs +++ b/WardrobeManager.Presentation/ViewModels/NavMenuViewModel.cs @@ -8,7 +8,6 @@ namespace WardrobeManager.Presentation.ViewModels; [ViewModelDefinition(Lifetime = ServiceLifetime.Scoped)] public partial class NavBarViewModel( - INotificationService notificationService, IMvvmNavigationManager navManager, IIdentityService identityService, IApiService apiService @@ -17,7 +16,7 @@ IApiService apiService { [ObservableProperty] private bool _showUserPopover; [ObservableProperty] private bool _canConnectToBackend; - [ObservableProperty] private string _usersName; + [ObservableProperty] private string _usersName = string.Empty; public void ToggleUserPopover() => ShowUserPopover = !ShowUserPopover; diff --git a/WardrobeManager.Presentation/ViewModels/SignupViewModel.cs b/WardrobeManager.Presentation/ViewModels/SignupViewModel.cs index 4f74ee4..3dce53f 100644 --- a/WardrobeManager.Presentation/ViewModels/SignupViewModel.cs +++ b/WardrobeManager.Presentation/ViewModels/SignupViewModel.cs @@ -12,7 +12,6 @@ namespace WardrobeManager.Presentation.ViewModels; [ViewModelDefinition(Lifetime = ServiceLifetime.Scoped)] public partial class SignupViewModel( - IAccountManagement accountManagement, INotificationService notificationService, IMvvmNavigationManager navManager, IIdentityService identityService diff --git a/WardrobeManager.Presentation/ViewModels/WardrobeViewModel.cs b/WardrobeManager.Presentation/ViewModels/WardrobeViewModel.cs new file mode 100644 index 0000000..dd73d4d --- /dev/null +++ b/WardrobeManager.Presentation/ViewModels/WardrobeViewModel.cs @@ -0,0 +1,104 @@ +using Blazing.Mvvm.ComponentModel; +using Blazing.Mvvm.Components; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Web; +using WardrobeManager.Presentation.Identity; +using WardrobeManager.Presentation.Services.Interfaces; +using WardrobeManager.Shared.DTOs; +using WardrobeManager.Shared.Enums; +using WardrobeManager.Shared.Models; + +namespace WardrobeManager.Presentation.ViewModels; + +[ViewModelDefinition(Lifetime = ServiceLifetime.Scoped)] +public partial class WardrobeViewModel( + IApiService apiService, + INotificationService notificationService +) + : ViewModelBase +{ + // Public Properties + [ObservableProperty] private List? _clothingItems; + + [ObservableProperty] + private Dictionary _actionDialogStates = new Dictionary(); + + public override async Task OnInitializedAsync() + { + await FetchItemAndUpdate(); + } + + public async Task FetchItemAndUpdate() + { + ClothingItems = await apiService.GetAllClothingItemsAsync(); + // Reset dialogs states (in case items are removed, they wont exist in this anymore) + ActionDialogStates = new Dictionary(); + foreach (var item in ClothingItems) + { + ActionDialogStates.Add(item.Id, new ShowActionDialog()); + } + } + + public async Task RemoveItem(int itemId) + { + await apiService.DeleteClothingItemAsync(itemId); + await FetchItemAndUpdate(); + } + + public void UpdateActionDialogState(int itemId, ActionType actionType, bool value) + { + if (ActionDialogStates.TryGetValue(itemId, out ShowActionDialog? actionDialog)) + { + switch (actionType) + { + case ActionType.Delete: + actionDialog.ShowDelete = value; + break; + case ActionType.Edit: + actionDialog.ShowDelete = value; + break; + default: + notificationService.AddNotification("Action type not recognized!", NotificationType.Warning); + break; + } + } + else + { + notificationService.AddNotification("Cannot change dialog state for not existing item!", + NotificationType.Error); + } + } + + public bool GetActionStateSafely(int itemId, ActionType actionType) + { + if (ActionDialogStates.TryGetValue(itemId, out ShowActionDialog? actionDialog)) + { + switch (actionType) + { + case ActionType.Delete: + return actionDialog.ShowDelete; + + case ActionType.Edit: + return actionDialog.ShowEdit; + default: + notificationService.AddNotification("Action type not recognized!", NotificationType.Warning); + break; + } + } + + return false; + } +} + +public record ShowActionDialog +{ + public bool ShowDelete = false; + public bool ShowEdit = false; +} + +public enum ActionType +{ + Delete, + Edit, +} \ No newline at end of file diff --git a/WardrobeManager.Presentation/WardrobeManager.Presentation.csproj b/WardrobeManager.Presentation/WardrobeManager.Presentation.csproj index a01caed..7ec690a 100644 --- a/WardrobeManager.Presentation/WardrobeManager.Presentation.csproj +++ b/WardrobeManager.Presentation/WardrobeManager.Presentation.csproj @@ -4,6 +4,7 @@ net10.0 enable enable + cbbf5e07-4619-4677-ae57-34c9bddbe174 diff --git a/WardrobeManager.Presentation/tailwind.css b/WardrobeManager.Presentation/tailwind.css index d34b175..e45a7c2 100644 --- a/WardrobeManager.Presentation/tailwind.css +++ b/WardrobeManager.Presentation/tailwind.css @@ -16,4 +16,68 @@ ::file-selector-button { border-color: var(--color-gray-200, currentcolor); } -} \ No newline at end of file +} + +@theme { + --font-serif: "DM Serif Display", serif; + --font-sans: "DM Sans", sans-serif; + + --font-size-title: 6rem; + --font-size-heading: 4rem; + --font-size-subheading: 2.25rem; + --font-size-subtitle: 1.5rem; + --font-size-body: 1rem; + --font-size-small: 0.7rem; +} + + +@utility title-text { + font-family: theme(--font-serif); + font-weight: 400; + font-style: normal; + font-size: theme(--font-size-title); +} + +@utility title-text-italic { + font-family: theme(--font-serif); + font-weight: 400; + font-style: italic; + font-size: theme(--font-size-title); +} + +@utility heading-text { + font-family: theme(--font-serif); + font-weight: 400; + font-style: normal; + font-size: theme(--font-size-heading); +} + +@utility subheading-text { + font-family: theme(--font-serif); + font-weight: 400; + font-style: normal; + font-size: theme(--font-size-subheading); +} + +@utility subtitle-text { + font-family: theme(--font-sans); + font-weight: 400; + font-style: normal; + font-size: theme(--font-size-subtitle); +} + +@utility body-text { + font-family: theme(--font-sans); + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; + font-size: theme(--font-size-body); +} + +@utility small-text { + font-family: theme(--font-sans); + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; + font-size: theme(--font-size-small); +} diff --git a/WardrobeManager.Presentation/wwwroot/css/custom.css b/WardrobeManager.Presentation/wwwroot/css/custom.css index c34cffe..e69de29 100644 --- a/WardrobeManager.Presentation/wwwroot/css/custom.css +++ b/WardrobeManager.Presentation/wwwroot/css/custom.css @@ -1,51 +0,0 @@ -/* Font Classes with size */ -.title-text { - font-family: "DM Serif Display", serif; - font-weight: 400; - font-style: normal; - font-size: 6rem; -} - -.title-text-italic { - font-family: "DM Serif Display", serif; - font-weight: 400; - font-style: italic; - font-size: 6rem; -} - -.heading-text { - font-family: "DM Serif Display", serif; - font-weight: 400; - font-style: normal; - font-size: 4rem; -} - -.subheading-text { - font-family: "DM Serif Display", serif; - font-weight: 400; - font-style: normal; - font-size: 2.25rem; -} - -.subtitle-text { - font-family: "DM Sans", sans-serif; - font-weight: 400; - font-style: normal; - font-size: 1.5rem; -} - -.body-text { - font-family: "DM Sans", sans-serif; - font-optical-sizing: auto; - font-weight: 400; /* 100-1000 */ - font-style: normal; - font-size: 1rem; -} - -.small-text{ - font-family: "DM Sans", sans-serif; - font-optical-sizing: auto; - font-weight: 400; /* 100-1000 */ - font-style: normal; - font-size: 0.7rem; -} diff --git a/WardrobeManager.Presentation/wwwroot/css/sysinfocus-styles.css b/WardrobeManager.Presentation/wwwroot/css/sysinfocus-styles.css index cde812d..512d953 100644 --- a/WardrobeManager.Presentation/wwwroot/css/sysinfocus-styles.css +++ b/WardrobeManager.Presentation/wwwroot/css/sysinfocus-styles.css @@ -289,25 +289,25 @@ input, textarea, select, button { gap: 12px; } -h1 { - font-size: 36px; - letter-spacing: -0.85px; -} +/*h1 {*/ +/* font-size: 36px;*/ +/* letter-spacing: -0.85px;*/ +/*}*/ -h2 { - font-size: 30px; - letter-spacing: -0.85px; -} +/*h2 {*/ +/* font-size: 30px;*/ +/* letter-spacing: -0.85px;*/ +/*}*/ -h3 { - font-size: 24px; - letter-spacing: -0.85px; -} +/*h3 {*/ +/* font-size: 24px;*/ +/* letter-spacing: -0.85px;*/ +/*}*/ -h4 { - font-size: 20px; - letter-spacing: -0.25px; -} +/*h4 {*/ +/* font-size: 20px;*/ +/* letter-spacing: -0.25px;*/ +/*}*/ .lead { font-size: 20px; diff --git a/WardrobeManager.Presentation/wwwroot/index.html b/WardrobeManager.Presentation/wwwroot/index.html index 7333cf8..54ce78e 100644 --- a/WardrobeManager.Presentation/wwwroot/index.html +++ b/WardrobeManager.Presentation/wwwroot/index.html @@ -14,7 +14,7 @@ - + diff --git a/WardrobeManager.Shared.Tests/StaticResources/MiscMethodsTests.cs b/WardrobeManager.Shared.Tests/StaticResources/MiscMethodsTests.cs deleted file mode 100644 index 1092086..0000000 --- a/WardrobeManager.Shared.Tests/StaticResources/MiscMethodsTests.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FluentAssertions; -using WardrobeManager.Shared.StaticResources; - -namespace WardrobeManager.Shared.Tests.StaticResources; - -public class MiscMethodsTests -{ - [SetUp] - public void Setup() - { - } - - [Test] - public void GenerateRandomId_DefaultScenario_CorrectFormat() - { - // Arrange - // Act - var randomId = MiscMethods.GenerateRandomId(); - // Assert - randomId.Length.Should().Be(10); - randomId.Should().StartWith("id"); - } -} \ No newline at end of file diff --git a/WardrobeManager.Shared.Tests/WardrobeManager.Shared.Tests.csproj b/WardrobeManager.Shared.Tests/WardrobeManager.Shared.Tests.csproj index a4dc5db..686c750 100644 --- a/WardrobeManager.Shared.Tests/WardrobeManager.Shared.Tests.csproj +++ b/WardrobeManager.Shared.Tests/WardrobeManager.Shared.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/WardrobeManager.Shared/DTOs/ClothingItemDTO.cs b/WardrobeManager.Shared/DTOs/ClothingItemDTO.cs new file mode 100644 index 0000000..d06dabd --- /dev/null +++ b/WardrobeManager.Shared/DTOs/ClothingItemDTO.cs @@ -0,0 +1,17 @@ +#region + +using WardrobeManager.Shared.Enums; + +#endregion + +namespace WardrobeManager.Shared.DTOs; + +public class ClothingItemDTO +{ + public int Id { get; set; } + public string Name { get; set; } + public ClothingCategory Category { get; set; } + public ClothingSize Size { get; set; } + public Guid? ImageGuid { get; set; } +} + diff --git a/WardrobeManager.Shared/Models/EditedUserDTO.cs b/WardrobeManager.Shared/DTOs/EditedUserDTO.cs similarity index 95% rename from WardrobeManager.Shared/Models/EditedUserDTO.cs rename to WardrobeManager.Shared/DTOs/EditedUserDTO.cs index 105c7df..8a3e785 100644 --- a/WardrobeManager.Shared/Models/EditedUserDTO.cs +++ b/WardrobeManager.Shared/DTOs/EditedUserDTO.cs @@ -1,4 +1,4 @@ -namespace WardrobeManager.Shared.Models; +namespace WardrobeManager.Shared.DTOs; /// DTO that client sends to edit their user details public class EditedUserDTO diff --git a/WardrobeManager.Shared/DTOs/NewClothingItemDTO.cs b/WardrobeManager.Shared/DTOs/NewClothingItemDTO.cs new file mode 100644 index 0000000..54a6394 --- /dev/null +++ b/WardrobeManager.Shared/DTOs/NewClothingItemDTO.cs @@ -0,0 +1,17 @@ +#region + +using System.ComponentModel.DataAnnotations; +using WardrobeManager.Shared.Enums; + +#endregion + +namespace WardrobeManager.Shared.DTOs; + +public class NewClothingItemDTO +{ + public string Name { get; set; } = string.Empty; + public ClothingCategory Category { get; set; } = ClothingCategory.None; + public string? ImageBase64 { get; set; } = null; + public ClothingSize Size { get; set; } = ClothingSize.NotSpecified; +} + diff --git a/WardrobeManager.Shared/Enums/ClothingSize.cs b/WardrobeManager.Shared/Enums/ClothingSize.cs new file mode 100644 index 0000000..7eaea5d --- /dev/null +++ b/WardrobeManager.Shared/Enums/ClothingSize.cs @@ -0,0 +1,13 @@ +namespace WardrobeManager.Shared.Enums; + +public enum ClothingSize +{ + NotSpecified = 0, + ExtraExtraSmall = 1, + ExtraSmall = 2, + Small = 3, + Medium = 4, + Large = 5, + ExtraLarge = 6, + ExtraExtraLarge = 7, +} \ No newline at end of file diff --git a/WardrobeManager.Shared/Models/ClientClothingItem.cs b/WardrobeManager.Shared/Models/ClientClothingItem.cs deleted file mode 100644 index 03b267b..0000000 --- a/WardrobeManager.Shared/Models/ClientClothingItem.cs +++ /dev/null @@ -1,16 +0,0 @@ -#region - -using WardrobeManager.Shared.Enums; - -#endregion - -namespace WardrobeManager.Shared.Models; - -public class ClientClothingItem(string name, ClothingCategory category, string ImageBase64) -{ - public int Id { get; set; } - public string Name { get; set; } = name; - public ClothingCategory Category { get; set; } = category; - public string ImageBase64 { get; set; } = ImageBase64; -} - diff --git a/WardrobeManager.Shared/Models/NewOrEditedClothingItemDTO.cs b/WardrobeManager.Shared/Models/NewOrEditedClothingItemDTO.cs deleted file mode 100644 index c9218c5..0000000 --- a/WardrobeManager.Shared/Models/NewOrEditedClothingItemDTO.cs +++ /dev/null @@ -1,38 +0,0 @@ -#region - -using WardrobeManager.Shared.Enums; - -#endregion - -namespace WardrobeManager.Shared.Models; - -/// DTO that client sends to create new clothing item -public class NewOrEditedClothingItemDTO -{ -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - public NewOrEditedClothingItemDTO() { } // ONLY FOR DESERIALIZER. THIS SHIT BETTER HAVE NO REFERENCES -#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - - - public NewOrEditedClothingItemDTO - ( - string name, ClothingCategory category, Season season, bool favourited, - WearLocation wearLocation, int desiredTimesWornBeforeWash, string imageBase64 - ) { - this.Name = name; - this.Category = category; - this.Season = season; - this.Favourited = favourited; - this.WearLocation = wearLocation; - this.DesiredTimesWornBeforeWash = desiredTimesWornBeforeWash; - this.ImageBase64 = imageBase64; - } - - public string Name { get; set; } - public ClothingCategory Category { get; set; } - public Season Season { get; set; } - public bool Favourited { get; set; } - public WearLocation WearLocation { get; set; } - public int DesiredTimesWornBeforeWash { get; set; } - public string ImageBase64 { get; set; } -} diff --git a/WardrobeManager.Shared/Models/Result.cs b/WardrobeManager.Shared/Models/Result.cs new file mode 100644 index 0000000..5f0dcf5 --- /dev/null +++ b/WardrobeManager.Shared/Models/Result.cs @@ -0,0 +1,8 @@ +namespace WardrobeManager.Shared.Models; + +public record Result(T Data, bool Success, string Message = "") +{ + public T Data { get; set; } = Data; + public bool Success { get; set; } = Success; + public string Message { get; set; } = Message; +} \ No newline at end of file diff --git a/WardrobeManager.Shared/Services/IMiscMethods.cs b/WardrobeManager.Shared/Services/IMiscMethods.cs new file mode 100644 index 0000000..414855e --- /dev/null +++ b/WardrobeManager.Shared/Services/IMiscMethods.cs @@ -0,0 +1,15 @@ +using WardrobeManager.Shared.Enums; + +namespace WardrobeManager.Shared.StaticResources; + +public interface IMiscMethods +{ + string GenerateRandomId(); + string GetEmoji(ClothingCategory category); + string GetEmoji(Season season); + string GetNameWithSpacesAndEmoji(ClothingCategory category); + string GetNameWithSpacesAndEmoji(Season season); + string GetNameWithSpacesFromEnum(T givenEnum) where T : Enum; + bool IsValidBase64(string? input); + ICollection ConvertEnumToCollection() where T : Enum; +} \ No newline at end of file diff --git a/WardrobeManager.Shared/StaticResources/MiscMethods.cs b/WardrobeManager.Shared/Services/MiscMethods.cs similarity index 70% rename from WardrobeManager.Shared/StaticResources/MiscMethods.cs rename to WardrobeManager.Shared/Services/MiscMethods.cs index 5b67805..fb2a09e 100644 --- a/WardrobeManager.Shared/StaticResources/MiscMethods.cs +++ b/WardrobeManager.Shared/Services/MiscMethods.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text.RegularExpressions; +using WardrobeManager.Shared.DTOs; using WardrobeManager.Shared.Enums; using WardrobeManager.Shared.Models; @@ -9,21 +10,16 @@ namespace WardrobeManager.Shared.StaticResources; -public static class MiscMethods +public class MiscMethods : IMiscMethods { - - public static NewOrEditedClothingItemDTO CreateDefaultNewOrEditedClothingItemDTO() { - return new NewOrEditedClothingItemDTO("My Favourite Green T-Shirt", ClothingCategory.TShirt, Season.Fall, false, WearLocation.HomeAndOutside, 5, ""); - } - - // This is by no means actually random - public static string GenerateRandomId() + public string GenerateRandomId() { const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; string id = RandomNumberGenerator.GetString(chars, 8); return $"id{id}"; // id needs to start with letter } - public static string GetEmoji(ClothingCategory category) + + public string GetEmoji(ClothingCategory category) { return category switch { @@ -37,7 +33,8 @@ public static string GetEmoji(ClothingCategory category) _ => "" }; } - public static string GetEmoji(Season season) + + public string GetEmoji(Season season) { return season switch { @@ -54,32 +51,24 @@ public static string GetEmoji(Season season) }; } - public static string GetNameWithSpacesAndEmoji(ClothingCategory category) + public string GetNameWithSpacesFromEnum(T givenEnum) where T : Enum { - var words = Regex.Matches(category.ToString(), @"([A-Z][a-z]+)").Select(m => m.Value); - var cat = category; - var withSpaces = string.Join(" ", words); - var emoji = GetEmoji(cat); - - return $"{emoji} {withSpaces}"; + return givenEnum switch + { + ClothingCategory category => GetNameWithSpacesAndEmoji(category), + Season season => GetNameWithSpacesAndEmoji(season), + _ => GetNameWithSpaces(givenEnum) + }; } - public static string GetNameWithSpacesAndEmoji(Season season) - { - var words = Regex.Matches(season.ToString(), @"([A-Z][a-z]+)").Select(m => m.Value); - var cat = season; - var withSpaces = string.Join(" ", words); - var emoji = GetEmoji(cat); - return $"{emoji} {withSpaces}"; - } - - public static string GetNameWithSpacesFromEnum(T givenEnum) where T : Enum { + private string GetNameWithSpaces(T givenEnum) where T : Enum + { var words = Regex.Matches(givenEnum.ToString(), @"([A-Z][a-z]+)").Select(m => m.Value); var withSpaces = string.Join(" ", words); return $"{withSpaces}"; } - - public static bool IsValidBase64(string input) + + public bool IsValidBase64(string? input) { try { @@ -87,13 +76,42 @@ public static bool IsValidBase64(string input) { return false; } + Convert.FromBase64String(input); return true; } catch { return false; - } } + + public ICollection ConvertEnumToCollection() where T : Enum + { + return Enum.GetValues(typeof(T)).Cast().ToList(); + } + + #region Private Methods + + public string GetNameWithSpacesAndEmoji(ClothingCategory category) + { + var words = Regex.Matches(category.ToString(), @"([A-Z][a-z]+)").Select(m => m.Value); + var cat = category; + var withSpaces = string.Join(" ", words); + var emoji = GetEmoji(cat); + + return $"{emoji} {withSpaces}"; + } + + public string GetNameWithSpacesAndEmoji(Season season) + { + var words = Regex.Matches(season.ToString(), @"([A-Z][a-z]+)").Select(m => m.Value); + var cat = season; + var withSpaces = string.Join(" ", words); + var emoji = GetEmoji(cat); + + return $"{emoji} {withSpaces}"; + } + + #endregion } \ No newline at end of file diff --git a/WardrobeManager.Shared/StaticResources/ProjectConstants.cs b/WardrobeManager.Shared/StaticResources/ProjectConstants.cs index bfb5dd3..68933b5 100644 --- a/WardrobeManager.Shared/StaticResources/ProjectConstants.cs +++ b/WardrobeManager.Shared/StaticResources/ProjectConstants.cs @@ -10,4 +10,5 @@ public static class ProjectConstants public const string ProfileImage = "https://upload.internal.connectwithmusa.com/file/eel-falcon-pig"; public const string DefaultItemImage = "/img/defaultItem.webp"; public const string HomeBackgroundImage = "/img/home-background.webp"; + public const int MaxImageSizeInMBFallback = 5; } diff --git a/WardrobeManager.Shared/StaticResources/StaticValidators.cs b/WardrobeManager.Shared/StaticResources/StaticValidators.cs new file mode 100644 index 0000000..c35b007 --- /dev/null +++ b/WardrobeManager.Shared/StaticResources/StaticValidators.cs @@ -0,0 +1,49 @@ +using WardrobeManager.Shared.DTOs; +using FluentValidation; +using WardrobeManager.Shared.Models; + +namespace WardrobeManager.Shared.StaticResources; + +// Generic validation method for all validators +public static class StaticValidators +{ + // A registry of validators (Map Type -> Validator Instance) + private static readonly Dictionary _validators = new() + { + { typeof(NewClothingItemDTO), new NewClothingItemDTOValidator() } + }; + + public static Result Validate(T input) + { + if (input is null) return new Result(input, false, "Input cannot be null"); + + // Look for a registered validator for this type + if (_validators.TryGetValue(typeof(T), out var validator)) + { + var context = new ValidationContext(input); + var validationResult = validator.Validate(context); + + if (!validationResult.IsValid) + { + // Join all error messages into one string + string errors = string.Join(" ", validationResult.Errors.Select(e => e.ErrorMessage)); + return new Result(input, false, errors); + } + } + + // If no validator is found, we assume it's valid by default + return new Result(input, true); + } +} + + +// All static validators below (in one file to stay clean) +public class NewClothingItemDTOValidator : AbstractValidator +{ + public NewClothingItemDTOValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("New clothing item must have a name.") + .MaximumLength(50).WithMessage("Name is too long."); + } +} diff --git a/WardrobeManager.Shared/WardrobeManager.Shared.csproj b/WardrobeManager.Shared/WardrobeManager.Shared.csproj index b760144..e9b37a4 100644 --- a/WardrobeManager.Shared/WardrobeManager.Shared.csproj +++ b/WardrobeManager.Shared/WardrobeManager.Shared.csproj @@ -1,9 +1,16 @@  - - net10.0 - enable - enable - + + net10.0 + enable + enable + + + CS8618 + + + + +