From d55bf4f82f99849004cb3492c53a2ade48856956 Mon Sep 17 00:00:00 2001 From: Khwezi Mngoma Date: Mon, 25 May 2026 22:18:53 +0200 Subject: [PATCH] Created Author, Book, AuthorBook, Page and Product with Price --- .../AuthorBooks/BooksService.cs | 104 ++++++++ .../AuthorBooks/Entities/AuthorBook.cs | 14 + .../Entities/AuthorBookConfiguration.cs | 28 ++ .../AuthorBooks/Models/AuthorBook.cs | 20 ++ .../Authors/AuthorService.cs | 182 +++++++++++++ .../Authors/Entities/Author.cs | 9 + .../Authors/Entities/AuthorConfiguration.cs | 24 ++ .../Authors/Models/Author.cs | 36 +++ .../Authors/Models/Records.cs | 30 +++ LiteCharms.Features.MidrandBooks/Enums.cs | 62 +++++ .../Extensions/HealthChecks.cs | 2 +- .../Extensions/Mappers.cs | 84 ++++++ .../Extensions/Postgres.cs | 6 +- .../PostgresMidrandShopHealthCheck.cs | 6 +- .../Pages/Entities/BookPage.cs | 9 + .../Pages/Entities/BookPageConfiguration.cs | 26 ++ .../Pages/Models/BookPage.cs | 28 ++ .../Pages/Models/CreateBookPage.cs | 18 ++ .../Pages/Models/UpdateBookPage.cs | 3 + .../Pages/PagesService.cs | 225 ++++++++++++++++ .../Postgres/MidrandBooksDbContext.cs | 19 ++ ...ory.cs => MidrandBooksDbContextFactory.cs} | 12 +- .../Postgres/MidrandShopDbContext.cs | 6 - .../Products/Entities/Product.cs | 7 + .../Products/Entities/ProductConfiguration.cs | 22 ++ .../Products/Entities/ProductPrice.cs | 7 + .../Entities/ProductPriceConfiguration.cs | 22 ++ .../Products/Models/CreateProductPrice.cs | 10 + .../Products/Models/Product.cs | 30 +++ .../Products/Models/ProductPrice.cs | 18 ++ .../Products/Models/Records.cs | 22 ++ .../Products/ProductService.cs | 252 ++++++++++++++++++ .../Customers/CustomerService.cs | 3 +- .../Products/Models/Product.cs | 4 +- LiteCharms.Features/Enums.cs | 13 + LiteCharms.Features/Models/PageReference.cs | 10 + LiteCharms.Features/Models/ProductFilter.cs | 18 ++ .../Models/ProductMetadata.cs | 2 +- LiteCharms.Features/Models/SocialMedia.cs | 13 + 39 files changed, 1383 insertions(+), 23 deletions(-) create mode 100644 LiteCharms.Features.MidrandBooks/AuthorBooks/BooksService.cs create mode 100644 LiteCharms.Features.MidrandBooks/AuthorBooks/Entities/AuthorBook.cs create mode 100644 LiteCharms.Features.MidrandBooks/AuthorBooks/Entities/AuthorBookConfiguration.cs create mode 100644 LiteCharms.Features.MidrandBooks/AuthorBooks/Models/AuthorBook.cs create mode 100644 LiteCharms.Features.MidrandBooks/Authors/AuthorService.cs create mode 100644 LiteCharms.Features.MidrandBooks/Authors/Entities/Author.cs create mode 100644 LiteCharms.Features.MidrandBooks/Authors/Entities/AuthorConfiguration.cs create mode 100644 LiteCharms.Features.MidrandBooks/Authors/Models/Author.cs create mode 100644 LiteCharms.Features.MidrandBooks/Authors/Models/Records.cs create mode 100644 LiteCharms.Features.MidrandBooks/Enums.cs create mode 100644 LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs create mode 100644 LiteCharms.Features.MidrandBooks/Pages/Entities/BookPage.cs create mode 100644 LiteCharms.Features.MidrandBooks/Pages/Entities/BookPageConfiguration.cs create mode 100644 LiteCharms.Features.MidrandBooks/Pages/Models/BookPage.cs create mode 100644 LiteCharms.Features.MidrandBooks/Pages/Models/CreateBookPage.cs create mode 100644 LiteCharms.Features.MidrandBooks/Pages/Models/UpdateBookPage.cs create mode 100644 LiteCharms.Features.MidrandBooks/Pages/PagesService.cs create mode 100644 LiteCharms.Features.MidrandBooks/Postgres/MidrandBooksDbContext.cs rename LiteCharms.Features.MidrandBooks/Postgres/{MidrandShopDbContextFactory.cs => MidrandBooksDbContextFactory.cs} (58%) delete mode 100644 LiteCharms.Features.MidrandBooks/Postgres/MidrandShopDbContext.cs create mode 100644 LiteCharms.Features.MidrandBooks/Products/Entities/Product.cs create mode 100644 LiteCharms.Features.MidrandBooks/Products/Entities/ProductConfiguration.cs create mode 100644 LiteCharms.Features.MidrandBooks/Products/Entities/ProductPrice.cs create mode 100644 LiteCharms.Features.MidrandBooks/Products/Entities/ProductPriceConfiguration.cs create mode 100644 LiteCharms.Features.MidrandBooks/Products/Models/CreateProductPrice.cs create mode 100644 LiteCharms.Features.MidrandBooks/Products/Models/Product.cs create mode 100644 LiteCharms.Features.MidrandBooks/Products/Models/ProductPrice.cs create mode 100644 LiteCharms.Features.MidrandBooks/Products/Models/Records.cs create mode 100644 LiteCharms.Features.MidrandBooks/Products/ProductService.cs create mode 100644 LiteCharms.Features/Models/PageReference.cs create mode 100644 LiteCharms.Features/Models/ProductFilter.cs rename {LiteCharms.Features.TechShop/Products => LiteCharms.Features}/Models/ProductMetadata.cs (79%) create mode 100644 LiteCharms.Features/Models/SocialMedia.cs diff --git a/LiteCharms.Features.MidrandBooks/AuthorBooks/BooksService.cs b/LiteCharms.Features.MidrandBooks/AuthorBooks/BooksService.cs new file mode 100644 index 0000000..f3ba48b --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/AuthorBooks/BooksService.cs @@ -0,0 +1,104 @@ +using LiteCharms.Features.MidrandBooks.AuthorBooks.Models; +using LiteCharms.Features.MidrandBooks.Extensions; +using LiteCharms.Features.MidrandBooks.Postgres; + +namespace LiteCharms.Features.MidrandBooks.AuthorBooks; + +public class BooksService(IDbContextFactory contextFactory) +{ + public async ValueTask UpdateBookStatusAsync(long bookId, bool isEnabled, CancellationToken cancellationToken) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var book = await context.Books.FirstOrDefaultAsync(b => b.Id == bookId, cancellationToken); + + if (book is null) + return Result.Fail(new Error($"Book with ID {bookId} not found")); + + book.UpdatedAt = DateTime.UtcNow; + book.Enabled = isEnabled; + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail(new Error($"Failed to change status of book with ID {bookId}")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> PublishBookAsync(long authorId, long productId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if(!await context.Authors.AnyAsync(a => a.Id == authorId, cancellationToken)) + return Result.Fail("Author not found."); + + if (!await context.Products.AnyAsync(p => p.Id == productId, cancellationToken)) + return Result.Fail("Product not found."); + + var book = context.Books.Add(new Entities.AuthorBook + { + AuthorId = authorId, + ProductId = productId, + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok(book.Entity.Id) + : Result.Fail("Failed to create book."); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetBookAsync(long bookId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var book = await context.Books + .AsNoTracking().FirstOrDefaultAsync(b => b.Id == bookId, cancellationToken); + + return book is null + ? Result.Fail(new Error($"Book with ID {bookId} not found")) + : Result.Ok(book.ToModel()); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetBooksByAuthorAsync(long authorId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if(!await context.Authors.AnyAsync(a => a.Id == authorId, cancellationToken)) + return Result.Fail(new Error($"Author with ID {authorId} not found")); + + var books = await context.Books + .AsNoTracking() + .OrderByDescending(b => b.CreatedAt) + .Where(b => b.AuthorId == authorId) + .ToListAsync(cancellationToken); + + return books?.Count > 0 + ? Result.Ok(books.Select(b => b.ToModel()).ToArray()) + : Result.Fail(new Error($"No books found for author with ID {authorId}")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/LiteCharms.Features.MidrandBooks/AuthorBooks/Entities/AuthorBook.cs b/LiteCharms.Features.MidrandBooks/AuthorBooks/Entities/AuthorBook.cs new file mode 100644 index 0000000..9c44f39 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/AuthorBooks/Entities/AuthorBook.cs @@ -0,0 +1,14 @@ +using LiteCharms.Features.MidrandBooks.Authors.Entities; +using LiteCharms.Features.MidrandBooks.Pages.Entities; +using LiteCharms.Features.MidrandBooks.Products.Entities; + +namespace LiteCharms.Features.MidrandBooks.AuthorBooks.Entities; + +public class AuthorBook : Models.AuthorBook +{ + public virtual Author Author { get; set; } = new(); + + public virtual Product Book { get; set; } = new(); + + public virtual ICollection Pages { get; set; } = []; +} diff --git a/LiteCharms.Features.MidrandBooks/AuthorBooks/Entities/AuthorBookConfiguration.cs b/LiteCharms.Features.MidrandBooks/AuthorBooks/Entities/AuthorBookConfiguration.cs new file mode 100644 index 0000000..1f18c56 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/AuthorBooks/Entities/AuthorBookConfiguration.cs @@ -0,0 +1,28 @@ +namespace LiteCharms.Features.MidrandBooks.AuthorBooks.Entities; + +public class AuthorBookConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Books"); + + builder.HasKey(f => f.AuthorId); + builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()"); + builder.Property(f => f.UpdatedAt).HasDefaultValueSql("now()"); + builder.Property(f => f.AuthorId).IsRequired(); + builder.Property(f => f.ProductId).IsRequired(); + builder.Property(f => f.Rating).IsRequired(false); + builder.Property(f => f.Ranking).IsRequired(false); + builder.Property(f => f.Enabled).HasDefaultValue(true); + + builder.HasOne(f => f.Author) + .WithMany(a => a.Books) + .HasForeignKey(f => f.AuthorId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(f => f.Book) + .WithMany() + .HasForeignKey(f => f.ProductId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/LiteCharms.Features.MidrandBooks/AuthorBooks/Models/AuthorBook.cs b/LiteCharms.Features.MidrandBooks/AuthorBooks/Models/AuthorBook.cs new file mode 100644 index 0000000..5d77fe9 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/AuthorBooks/Models/AuthorBook.cs @@ -0,0 +1,20 @@ +namespace LiteCharms.Features.MidrandBooks.AuthorBooks.Models; + +public class AuthorBook +{ + public long Id { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public long AuthorId { get; set; } + + public long ProductId { get; set; } + + public int Rating { get; set; } + + public int Ranking { get; set; } + + public bool Enabled { get; set; } +} diff --git a/LiteCharms.Features.MidrandBooks/Authors/AuthorService.cs b/LiteCharms.Features.MidrandBooks/Authors/AuthorService.cs new file mode 100644 index 0000000..b9cc707 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Authors/AuthorService.cs @@ -0,0 +1,182 @@ +using LiteCharms.Features.MidrandBooks.Authors.Models; +using LiteCharms.Features.MidrandBooks.Extensions; +using LiteCharms.Features.MidrandBooks.Postgres; +using LiteCharms.Features.MidrandBooks.Products.Models; +using LiteCharms.Features.Models; + +namespace LiteCharms.Features.MidrandBooks.Authors; + +public class AuthorService(IDbContextFactory contextFactory) +{ + public async ValueTask> GetAuthorBooksAsync(long authorId, CancellationToken cancellationToken) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var author = await context.Authors.FirstOrDefaultAsync(a => a.Id == authorId, cancellationToken); + + if (author is null) + return Result.Fail(new Error($"Author with ID {authorId} not found")); + + var books = await context.Books.AsNoTracking() + .OrderByDescending(b => b.CreatedAt) + .Where(p => p.AuthorId == authorId) + .Select(p => p.Book.ToModel()) + .ToArrayAsync(cancellationToken); + + return books?.Length > 0 + ? Result.Ok(books) + : Result.Fail(new Error($"No books found for author with ID {authorId}")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask UpdateAuthorStatusAsync(long authorId, bool isEnabled, CancellationToken cancellationToken) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var author = await context.Authors.FirstOrDefaultAsync(a => a.Id == authorId, cancellationToken); + + if (author is null) + return Result.Fail(new Error($"Author with ID {authorId} not found")); + + author.UpdatedAt = DateTime.UtcNow; + author.Enabled = isEnabled; + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail(new Error($"Failed to change status of author with ID {authorId}")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetAuthorAsync(long authorId, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var author = await context.Authors.FirstOrDefaultAsync(a => a.Id == authorId, cancellationToken); + + return author is not null + ? Result.Ok(author.ToModel()) + : Result.Fail(new Error($"Author with ID {authorId} not found")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetAuthors(DateRange range, CancellationToken cancellationToken) + { + try + { + var fromDate = range.From.ToDateTime(TimeOnly.MinValue); + var toDate = range.To.ToDateTime(TimeOnly.MaxValue); + + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var authors = await context.Authors.AsNoTracking() + .OrderByDescending(o => o.CreatedAt) + .ThenByDescending(o => o.UpdatedAt) + .Where(a => a.CreatedAt >= fromDate && a.CreatedAt <= toDate) + .Take(range.MaxRecords) + .ToArrayAsync(cancellationToken); + + return authors?.Length > 0 + ? Result.Ok(authors.Select(a => a.ToModel()).ToArray()) + : Result.Fail(new Error("No authors found in the specified date range.")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask UpdateAuthorAsync(long authorId, UpdateAuthor request, CancellationToken cancellationToken) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (await context.Authors.AnyAsync(a => a.Name == request.Name && a.LastName == request.LastName, cancellationToken)) + return Result.Fail(new Error($"An author with the name {request.Name} {request.LastName} already exists")); + + if (await context.Authors.AnyAsync(a => a.Email == request.Email, cancellationToken)) + return Result.Fail(new Error($"An author with the email {request.Email} already exists")); + + var author = await context.Authors.FirstOrDefaultAsync(a => a.Id == authorId, cancellationToken); + + if (author is null) + return Result.Fail(new Error($"Author with ID {authorId} not found")); + + author.UpdatedAt = DateTime.UtcNow; + author.PublisherType = request.PublisherType; + author.Company = request.Company; + author.VatNumber = request.VatNumber; + author.Name = request.Name; + author.LastName = request.LastName; + author.Biography = request.Biography; + author.Email = request.Email; + author.Website = request.Website; + author.ImageUrl = request.ImageUrl; + author.ThumbnailImageUrl = request.ThumbnailImageUrl; + author.SocialMedia = request.SocialMedia; + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail(new Error($"Failed to update author with ID {authorId}")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> CreateAuthorAsync(CreateAuthor request, CancellationToken cancellationToken = default) + { + try + { + using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if(await context.Authors.AnyAsync(a => a.Name == request.Name && a.LastName == request.LastName, cancellationToken)) + return Result.Fail(new Error($"An author with the name {request.Name} {request.LastName} already exists")); + + if(await context.Authors.AnyAsync(a => a.Email == request.Email, cancellationToken)) + return Result.Fail(new Error($"An author with the email {request.Email} already exists")); + + var newAuthor = context.Authors.Add(new Entities.Author + { + Company = request.Company, + VatNumber = request.VatNumber, + PublisherType = request.PublisherType, + Name = request.Name, + LastName = request.LastName, + Biography = request.Biography, + Email = request.Email, + Website = request.Website, + ImageUrl = request.ImageUrl, + ThumbnailImageUrl = request.ThumbnailImageUrl, + SocialMedia = request.SocialMedia + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok(newAuthor.Entity.Id) + : Result.Fail(new Error($"Failed to create author {request.Name} {request.LastName}")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/LiteCharms.Features.MidrandBooks/Authors/Entities/Author.cs b/LiteCharms.Features.MidrandBooks/Authors/Entities/Author.cs new file mode 100644 index 0000000..60b6a2b --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Authors/Entities/Author.cs @@ -0,0 +1,9 @@ +using LiteCharms.Features.MidrandBooks.AuthorBooks.Entities; + +namespace LiteCharms.Features.MidrandBooks.Authors.Entities; + +[EntityTypeConfiguration] +public class Author : Models.Author +{ + public ICollection Books { get; set; } = []; +} diff --git a/LiteCharms.Features.MidrandBooks/Authors/Entities/AuthorConfiguration.cs b/LiteCharms.Features.MidrandBooks/Authors/Entities/AuthorConfiguration.cs new file mode 100644 index 0000000..cd6eda1 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Authors/Entities/AuthorConfiguration.cs @@ -0,0 +1,24 @@ +namespace LiteCharms.Features.MidrandBooks.Authors.Entities; + +public class AuthorConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Authors"); + + builder.HasKey(f => f.Id); + builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()"); + builder.Property(f => f.UpdatedAt).HasDefaultValueSql("now()"); + builder.Property(f => f.PublisherType).IsRequired(); + builder.Property(f => f.VatNumber).IsRequired(false).HasMaxLength(255); + builder.Property(f => f.Name).IsRequired().HasMaxLength(255); + builder.Property(f => f.LastName).IsRequired().HasMaxLength(255); + builder.Property(f => f.Biography).IsRequired(false).HasMaxLength(2048); + builder.Property(f => f.Email).IsRequired().HasMaxLength(512); + builder.Property(f => f.Website).IsRequired(false).HasMaxLength(1024); + builder.Property(f => f.ImageUrl).IsRequired().HasMaxLength(2048); + builder.Property(f => f.ThumbnailImageUrl).IsRequired(false).HasMaxLength(2048); + builder.Property(f => f.SocialMedia).IsRequired(false).HasColumnType("jsonb"); + builder.Property(f => f.Enabled).HasDefaultValue(true); + } +} diff --git a/LiteCharms.Features.MidrandBooks/Authors/Models/Author.cs b/LiteCharms.Features.MidrandBooks/Authors/Models/Author.cs new file mode 100644 index 0000000..0546a7b --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Authors/Models/Author.cs @@ -0,0 +1,36 @@ +using LiteCharms.Features.Models; + +namespace LiteCharms.Features.MidrandBooks.Authors.Models; + +public class Author +{ + public long Id { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public PublisherTypes PublisherType { get; set; } + + public string? Company { get; set; } + + public string? VatNumber { get; set; } + + public string? Name { get; set; } + + public string? LastName { get; set; } + + public string? Biography { get; set; } + + public string? Email { get; set; } + + public string? Website { get; set; } + + public string? ImageUrl { get; set; } + + public string? ThumbnailImageUrl { get; set; } + + public SocialMedia[]? SocialMedia { get; set; } + + public bool Enabled { get; set; } +} diff --git a/LiteCharms.Features.MidrandBooks/Authors/Models/Records.cs b/LiteCharms.Features.MidrandBooks/Authors/Models/Records.cs new file mode 100644 index 0000000..ba54191 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Authors/Models/Records.cs @@ -0,0 +1,30 @@ +using LiteCharms.Features.Models; + +namespace LiteCharms.Features.MidrandBooks.Authors.Models; + +public record UpdateAuthor : CreateAuthor; + +public record CreateAuthor +{ + public required PublisherTypes PublisherType { get; set; } + + public string? Company { get; set; } + + public string? VatNumber { get; set; } + + public required string Name { get; set; } + + public required string LastName { get; set; } + + public string? Biography { get; set; } + + public required string Email { get; set; } + + public string? Website { get; set; } + + public required string ImageUrl { get; set; } + + public string? ThumbnailImageUrl { get; set; } + + public SocialMedia[]? SocialMedia { get; set; } +} diff --git a/LiteCharms.Features.MidrandBooks/Enums.cs b/LiteCharms.Features.MidrandBooks/Enums.cs new file mode 100644 index 0000000..b3325c2 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Enums.cs @@ -0,0 +1,62 @@ +namespace LiteCharms.Features.MidrandBooks; + +public enum PublisherTypes : int +{ + Individual = 0, + Company = 1, + Organization = 2, + SelfPublished = 3, + UniversityPress = 4, + GovernmentAgency = 5, + NonProfit = 6, + Independent = 7 +} + +public enum BookTypes : int +{ + Fiction = 0, + NonFiction = 1, + Academic = 2, + SelfHelp = 3, + Biography = 4, + Poetry = 5, + Children = 6, + YoungAdult = 7, + ScienceFiction = 8, + Fantasy = 9 +} + +public enum BookContentTypes : int +{ + Text = 0, + Image = 1, + Video = 2, + Audio = 3, + Interactive = 4, + Markdown = 5, + Html = 6, + Json = 7, + Yaml = 8 +} + +public enum BookPageTypes : int +{ + Cover = 0, + Preface = 1, + Introduction = 2, + Content = 3, + Closing = 4, + Referencer = 5, + Credits = 6, + BackCover = 7 +} + +public enum ProductTypes : int +{ + Book = 1, + Journal = 2, + Magazine = 3, + EBook = 4, + Audiobook = 5, + Accessory = 6 +} diff --git a/LiteCharms.Features.MidrandBooks/Extensions/HealthChecks.cs b/LiteCharms.Features.MidrandBooks/Extensions/HealthChecks.cs index fbc3e1f..bf35ee8 100644 --- a/LiteCharms.Features.MidrandBooks/Extensions/HealthChecks.cs +++ b/LiteCharms.Features.MidrandBooks/Extensions/HealthChecks.cs @@ -15,7 +15,7 @@ public static class HealthChecks public static IServiceCollection AddMidrandShopPostgresHealthCheck(this IServiceCollection services) { - services.AddHealthChecks().AddCheck(MidrandShopDbConfigName); + services.AddHealthChecks().AddCheck(MidrandBooksDbConfigName); return services; } diff --git a/LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs b/LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs new file mode 100644 index 0000000..21607dc --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Extensions/Mappers.cs @@ -0,0 +1,84 @@ +using LiteCharms.Features.MidrandBooks.AuthorBooks.Models; +using LiteCharms.Features.MidrandBooks.Authors.Models; +using LiteCharms.Features.MidrandBooks.Pages.Models; +using LiteCharms.Features.MidrandBooks.Products.Models; + +namespace LiteCharms.Features.MidrandBooks.Extensions; + +public static class Mappers +{ + public static BookPage ToModel(this Pages.Entities.BookPage entity) => new() + { + Id = entity.Id, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt, + AuthorBookId = entity.AuthorBookId, + Content = entity.Content, + ContentType = entity.ContentType, + Number = entity.Number, + Enabled = entity.Enabled, + Notes = entity.Notes, + References = entity.References, + Type = entity.Type + }; + + public static AuthorBook ToModel(this AuthorBooks.Entities.AuthorBook entity) => new() + { + Id = entity.Id, + ProductId = entity.ProductId, + CreatedAt = entity.CreatedAt, + AuthorId = entity.AuthorId, + Ranking = entity.Ranking, + Rating = entity.Rating, + Enabled = entity.Enabled + }; + + public static ProductPrice ToModel(this Products.Entities.ProductPrice entity) => new() + { + Id = entity.Id, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt, + ProductId = entity.ProductId, + Amount = entity.Amount, + Discount = entity.Discount, + Enabled = entity.Enabled + }; + + public static Product ToModel(this Products.Entities.Product entity) + { + return new Product + { + Id = entity.Id, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt, + Name = entity.Name, + Summary = entity.Summary, + Description = entity.Description, + Type = entity.Type, + ImageUrl = entity.ImageUrl, + ThumbnailUrls = entity.ThumbnailUrls, + Metadata = entity.Metadata, + Categories = entity.Categories, + Enabled = entity.Enabled + }; + } + + public static Author ToModel(this Authors.Entities.Author entity) => new() + { + Id = entity.Id, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt, + Name = entity.Name, + LastName = entity.LastName, + Biography = entity.Biography, + Email = entity.Email, + Website = entity.Website, + ImageUrl = entity.ImageUrl, + ThumbnailImageUrl = entity.ThumbnailImageUrl, + SocialMedia = entity.SocialMedia, + Enabled = entity.Enabled, + Company = entity.Company, + PublisherType = entity.PublisherType, + VatNumber = entity.VatNumber + }; +} diff --git a/LiteCharms.Features.MidrandBooks/Extensions/Postgres.cs b/LiteCharms.Features.MidrandBooks/Extensions/Postgres.cs index 86173d9..228634d 100644 --- a/LiteCharms.Features.MidrandBooks/Extensions/Postgres.cs +++ b/LiteCharms.Features.MidrandBooks/Extensions/Postgres.cs @@ -4,12 +4,12 @@ namespace LiteCharms.Features.MidrandBooks.Extensions; public static class Postgres { - public const string MidrandShopDbConfigName = "PostgresMidrandBooks"; + public const string MidrandBooksDbConfigName = "PostgresMidrandBooks"; public static IServiceCollection AddMidrandShopDatabase(this IServiceCollection services, IConfiguration configuration) { - services.AddPooledDbContextFactory(options => - options.UseNpgsql(configuration.GetConnectionString(MidrandShopDbConfigName))); + services.AddPooledDbContextFactory(options => + options.UseNpgsql(configuration.GetConnectionString(MidrandBooksDbConfigName))); return services; } diff --git a/LiteCharms.Features.MidrandBooks/HealthChecks/PostgresMidrandShopHealthCheck.cs b/LiteCharms.Features.MidrandBooks/HealthChecks/PostgresMidrandShopHealthCheck.cs index e60ef2c..5a8b44e 100644 --- a/LiteCharms.Features.MidrandBooks/HealthChecks/PostgresMidrandShopHealthCheck.cs +++ b/LiteCharms.Features.MidrandBooks/HealthChecks/PostgresMidrandShopHealthCheck.cs @@ -4,7 +4,7 @@ namespace LiteCharms.Features.MidrandBooks.HealthChecks; public class PostgresMidrandShopHealthCheck(IConfiguration configuration) : IHealthCheck { - private readonly string connectionString = configuration.GetConnectionString(MidrandShopDbConfigName)!; + private readonly string connectionString = configuration.GetConnectionString(MidrandBooksDbConfigName)!; public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { @@ -18,11 +18,11 @@ public class PostgresMidrandShopHealthCheck(IConfiguration configuration) : IHea await command.ExecuteScalarAsync(cancellationToken); - return HealthCheckResult.Healthy($"{MidrandShopDbConfigName} is responsive."); + return HealthCheckResult.Healthy($"{MidrandBooksDbConfigName} is responsive."); } catch (Exception ex) { - return HealthCheckResult.Unhealthy($"{MidrandShopDbConfigName} is unreachable.", ex); + return HealthCheckResult.Unhealthy($"{MidrandBooksDbConfigName} is unreachable.", ex); } } } \ No newline at end of file diff --git a/LiteCharms.Features.MidrandBooks/Pages/Entities/BookPage.cs b/LiteCharms.Features.MidrandBooks/Pages/Entities/BookPage.cs new file mode 100644 index 0000000..bba4436 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Pages/Entities/BookPage.cs @@ -0,0 +1,9 @@ +using LiteCharms.Features.MidrandBooks.AuthorBooks.Entities; + +namespace LiteCharms.Features.MidrandBooks.Pages.Entities; + +[EntityTypeConfiguration] +public class BookPage : Models.BookPage +{ + public virtual AuthorBook Book { get; set; } = new(); +} diff --git a/LiteCharms.Features.MidrandBooks/Pages/Entities/BookPageConfiguration.cs b/LiteCharms.Features.MidrandBooks/Pages/Entities/BookPageConfiguration.cs new file mode 100644 index 0000000..48c8196 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Pages/Entities/BookPageConfiguration.cs @@ -0,0 +1,26 @@ +namespace LiteCharms.Features.MidrandBooks.Pages.Entities; + +public class BookPageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("BookPages"); + + builder.HasKey(bp => bp.Id); + builder.Property(bp => bp.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()"); + builder.Property(bp => bp.UpdatedAt).HasDefaultValueSql("now()"); + builder.Property(bp => bp.Number).IsRequired().HasDefaultValue(0); + builder.Property(bp => bp.AuthorBookId).IsRequired(); + builder.Property(bp => bp.Content).IsRequired(); + builder.Property(bp => bp.Type).IsRequired(); + builder.Property(bp => bp.ContentType).IsRequired(); + builder.Property(bp => bp.Notes).IsRequired(false).HasColumnType("jsonb"); + builder.Property(bp => bp.References).IsRequired(false).HasColumnType("jsonb"); + builder.Property(bp => bp.Enabled).HasDefaultValue(true); + + builder.HasOne(f =>f.Book) + .WithMany(b => b.Pages) + .HasForeignKey(f => f.AuthorBookId) + .OnDelete(DeleteBehavior.NoAction); + } +} diff --git a/LiteCharms.Features.MidrandBooks/Pages/Models/BookPage.cs b/LiteCharms.Features.MidrandBooks/Pages/Models/BookPage.cs new file mode 100644 index 0000000..d95cfa1 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Pages/Models/BookPage.cs @@ -0,0 +1,28 @@ +using LiteCharms.Features.Models; + +namespace LiteCharms.Features.MidrandBooks.Pages.Models; + +public class BookPage +{ + public long Id { get; set; } + + public long AuthorBookId { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public BookPageTypes Type { get; set; } + + public BookContentTypes ContentType { get; set; } + + public int Number { get; set; } + + public byte[]? Content { get; set; } + + public string[]? Notes { get; set; } + + public PageReference[]? References { get; set; } + + public bool Enabled { get; set; } +} diff --git a/LiteCharms.Features.MidrandBooks/Pages/Models/CreateBookPage.cs b/LiteCharms.Features.MidrandBooks/Pages/Models/CreateBookPage.cs new file mode 100644 index 0000000..d4af2e7 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Pages/Models/CreateBookPage.cs @@ -0,0 +1,18 @@ +using LiteCharms.Features.Models; + +namespace LiteCharms.Features.MidrandBooks.Pages.Models; + +public class CreateBookPage +{ + public BookPageTypes Type { get; set; } + + public BookContentTypes ContentType { get; set; } + + public int Number { get; set; } + + public byte[]? Content { get; set; } + + public string[]? Notes { get; set; } + + public PageReference[]? References { get; set; } +} diff --git a/LiteCharms.Features.MidrandBooks/Pages/Models/UpdateBookPage.cs b/LiteCharms.Features.MidrandBooks/Pages/Models/UpdateBookPage.cs new file mode 100644 index 0000000..d78b732 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Pages/Models/UpdateBookPage.cs @@ -0,0 +1,3 @@ +namespace LiteCharms.Features.MidrandBooks.Pages.Models; + +public class UpdateBookPage : CreateBookPage; diff --git a/LiteCharms.Features.MidrandBooks/Pages/PagesService.cs b/LiteCharms.Features.MidrandBooks/Pages/PagesService.cs new file mode 100644 index 0000000..18c822b --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Pages/PagesService.cs @@ -0,0 +1,225 @@ +using LiteCharms.Features.MidrandBooks.Extensions; +using LiteCharms.Features.MidrandBooks.Pages.Models; +using LiteCharms.Features.MidrandBooks.Postgres; + +namespace LiteCharms.Features.MidrandBooks.Pages; + +public class PagesService(IDbContextFactory contextFactory) +{ + public async ValueTask DeleteAllAsync(long authorBookId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (!await context.Books.AnyAsync(b => b.Id == authorBookId, cancellationToken)) + return Result.Fail("Book not found"); + + var pages = await context.Pages.Where(p => p.AuthorBookId == authorBookId).ToListAsync(cancellationToken); + + if (pages.Count == 0) + return Result.Fail("No pages found for the specified book"); + + context.Pages.RemoveRange(pages); + + await context.SaveChangesAsync(cancellationToken); + + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask DeleteByPageTypeAsync(long authorBookId, int pageNumber, BookPageTypes pageType, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var page = await context.Pages.FirstOrDefaultAsync(p => p.AuthorBookId == authorBookId && p.Number == pageNumber && p.Type == pageType, cancellationToken); + + if (page is null) + return Result.Fail("Page not found"); + + context.Pages.Remove(page); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to delete page"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask UpdatePageStatusAsync(long bookPageId, bool enabled, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var page = await context.Pages.FirstOrDefaultAsync(p => p.Id == bookPageId, cancellationToken); + + if (page is null) + return Result.Fail("Page not found"); + + page.UpdatedAt = DateTime.UtcNow; + page.Enabled = enabled; + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to update page status"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask DeletePageAsync(long bookPageId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var page = await context.Pages.FirstOrDefaultAsync(p => p.Id == bookPageId, cancellationToken); + + if (page is null) + return Result.Fail("Page not found"); + + context.Pages.Remove(page); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to delete page"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask UpdatePageAsync(long bookPageId, UpdateBookPage request, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var page = await context.Pages.FirstOrDefaultAsync(p => p.Id == bookPageId, cancellationToken); + + if (page is null) + return Result.Fail("Page not found"); + + page.UpdatedAt = DateTime.UtcNow; + page.Type = request.Type; + page.ContentType = request.ContentType; + page.Number = request.Number; + page.Content = request.Content; + page.Notes = request.Notes; + page.References = request.References; + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail("Failed to update page"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> CreatePageAsync(long authorBookId, CreateBookPage request, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (!await context.Books.AnyAsync(b => b.Id == authorBookId, cancellationToken)) + return Result.Fail("Book not found"); + + if (await context.Pages.AnyAsync(p => p.AuthorBookId == authorBookId && p.Number == request.Number && p.Type == request.Type, cancellationToken)) + return Result.Fail("A page with the same number already exists for this book"); + + var page = context.Pages.Add(new Entities.BookPage + { + AuthorBookId = authorBookId, + Type = request.Type, + ContentType = request.ContentType, + Number = request.Number, + Content = request.Content, + Notes = request.Notes, + References = request.References, + Enabled = true + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok(page.Entity.Id) + : Result.Fail("Failed to create page"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetPagesAsync(long authorBookId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if (!await context.Books.AnyAsync(b => b.Id == authorBookId, cancellationToken)) + return Result.Fail("Book not found"); + + var pages = await context.Pages.AsNoTracking() + .Where(p => p.AuthorBookId == authorBookId).ToArrayAsync(cancellationToken); + + return pages?.Length > 0 + ? Result.Ok(pages.Select(p => p.ToModel()).ToArray()) + : Result.Fail("No pages found for the specified book"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetPageByNumberAsync(long pageId, int number, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var page = await context.Pages.FirstOrDefaultAsync(p => p.Id == pageId && p.Number == number, cancellationToken); + + return page is not null + ? page.ToModel() + : Result.Fail("Page not found"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetPageAsync(long pageId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var page = await context.Pages.FirstOrDefaultAsync(p => p.Id == pageId, cancellationToken); + + return page is not null + ? page.ToModel() + : Result.Fail("Page not found"); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/LiteCharms.Features.MidrandBooks/Postgres/MidrandBooksDbContext.cs b/LiteCharms.Features.MidrandBooks/Postgres/MidrandBooksDbContext.cs new file mode 100644 index 0000000..1bb907d --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Postgres/MidrandBooksDbContext.cs @@ -0,0 +1,19 @@ +using LiteCharms.Features.MidrandBooks.AuthorBooks.Entities; +using LiteCharms.Features.MidrandBooks.Authors.Entities; +using LiteCharms.Features.MidrandBooks.Pages.Entities; +using LiteCharms.Features.MidrandBooks.Products.Entities; + +namespace LiteCharms.Features.MidrandBooks.Postgres; + +public class MidrandBooksDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Authors => Set(); + + public DbSet Products => Set(); + + public DbSet Prices => Set(); + + public DbSet Books => Set(); + + public DbSet Pages => Set(); +} diff --git a/LiteCharms.Features.MidrandBooks/Postgres/MidrandShopDbContextFactory.cs b/LiteCharms.Features.MidrandBooks/Postgres/MidrandBooksDbContextFactory.cs similarity index 58% rename from LiteCharms.Features.MidrandBooks/Postgres/MidrandShopDbContextFactory.cs rename to LiteCharms.Features.MidrandBooks/Postgres/MidrandBooksDbContextFactory.cs index 6aca9e4..e518cfd 100644 --- a/LiteCharms.Features.MidrandBooks/Postgres/MidrandShopDbContextFactory.cs +++ b/LiteCharms.Features.MidrandBooks/Postgres/MidrandBooksDbContextFactory.cs @@ -2,20 +2,20 @@ namespace LiteCharms.Features.MidrandBooks.Postgres; -public class MidrandShopDbContextFactory : IDesignTimeDbContextFactory +public class MidrandBooksDbContextFactory : IDesignTimeDbContextFactory { - public MidrandShopDbContext CreateDbContext(string[] args) + public MidrandBooksDbContext CreateDbContext(string[] args) { var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) - .AddUserSecrets(typeof(MidrandShopDbContext).Assembly) + .AddUserSecrets(typeof(MidrandBooksDbContext).Assembly) .AddJsonFile("appsettings.json") .AddEnvironmentVariables() .Build(); - var optionsBuilder = new DbContextOptionsBuilder(); - optionsBuilder.UseNpgsql(configuration.GetConnectionString(MidrandShopDbConfigName)); + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(configuration.GetConnectionString(MidrandBooksDbConfigName)); - return new MidrandShopDbContext(optionsBuilder.Options); + return new MidrandBooksDbContext(optionsBuilder.Options); } } diff --git a/LiteCharms.Features.MidrandBooks/Postgres/MidrandShopDbContext.cs b/LiteCharms.Features.MidrandBooks/Postgres/MidrandShopDbContext.cs deleted file mode 100644 index 19e6697..0000000 --- a/LiteCharms.Features.MidrandBooks/Postgres/MidrandShopDbContext.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace LiteCharms.Features.MidrandBooks.Postgres; - -public class MidrandShopDbContext(DbContextOptions options) : DbContext(options) -{ - -} diff --git a/LiteCharms.Features.MidrandBooks/Products/Entities/Product.cs b/LiteCharms.Features.MidrandBooks/Products/Entities/Product.cs new file mode 100644 index 0000000..e7e82c6 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Products/Entities/Product.cs @@ -0,0 +1,7 @@ +namespace LiteCharms.Features.MidrandBooks.Products.Entities; + +[EntityTypeConfiguration] +public class Product : Models.Product +{ + public virtual ICollection Prices { get; set; } = []; +} diff --git a/LiteCharms.Features.MidrandBooks/Products/Entities/ProductConfiguration.cs b/LiteCharms.Features.MidrandBooks/Products/Entities/ProductConfiguration.cs new file mode 100644 index 0000000..71515b2 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Products/Entities/ProductConfiguration.cs @@ -0,0 +1,22 @@ +namespace LiteCharms.Features.MidrandBooks.Products.Entities; + +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Products"); + + builder.HasKey(f => f.Id); + builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()"); + builder.Property(f => f.UpdatedAt).HasDefaultValueSql("now()"); + builder.Property(f => f.Type).IsRequired(); + builder.Property(f => f.Name).IsRequired().HasMaxLength(255); + builder.Property(f => f.Summary).IsRequired().HasMaxLength(512); + builder.Property(f => f.Description).HasMaxLength(1024); + builder.Property(f => f.ImageUrl).HasMaxLength(1024); + builder.Property(f => f.ThumbnailUrls).IsRequired(false).HasColumnType("jsonb"); + builder.Property(f => f.Metadata).IsRequired(false).HasColumnType("jsonb"); + builder.Property(f => f.Categories).IsRequired(false).HasColumnType("jsonb"); + builder.Property(f => f.Enabled).HasDefaultValue(false); + } +} diff --git a/LiteCharms.Features.MidrandBooks/Products/Entities/ProductPrice.cs b/LiteCharms.Features.MidrandBooks/Products/Entities/ProductPrice.cs new file mode 100644 index 0000000..e78c42b --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Products/Entities/ProductPrice.cs @@ -0,0 +1,7 @@ +namespace LiteCharms.Features.MidrandBooks.Products.Entities; + +[EntityTypeConfiguration] +public class ProductPrice : Models.ProductPrice +{ + public virtual Product Product { get; set; } = new(); +} diff --git a/LiteCharms.Features.MidrandBooks/Products/Entities/ProductPriceConfiguration.cs b/LiteCharms.Features.MidrandBooks/Products/Entities/ProductPriceConfiguration.cs new file mode 100644 index 0000000..4705ba5 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Products/Entities/ProductPriceConfiguration.cs @@ -0,0 +1,22 @@ +namespace LiteCharms.Features.MidrandBooks.Products.Entities; + +public class ProductPriceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Prices"); + + builder.HasKey(f => f.Id); + builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()"); + builder.Property(f => f.UpdatedAt).HasDefaultValueSql("now()"); + builder.Property(f => f.ProductId).IsRequired(); + builder.Property(f => f.Amount).IsRequired().HasPrecision(18, 2); + builder.Property(f => f.Discount).IsRequired().HasPrecision(18, 2); + builder.Property(f => f.Enabled).HasDefaultValue(false); + + builder.HasOne(f => f.Product) + .WithMany(p => p.Prices) + .HasForeignKey(f => f.ProductId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/LiteCharms.Features.MidrandBooks/Products/Models/CreateProductPrice.cs b/LiteCharms.Features.MidrandBooks/Products/Models/CreateProductPrice.cs new file mode 100644 index 0000000..2fec81e --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Products/Models/CreateProductPrice.cs @@ -0,0 +1,10 @@ +namespace LiteCharms.Features.MidrandBooks.Products.Models; + +public class CreateProductPrice +{ + public long ProductId { get; set; } + + public decimal Amount { get; set; } + + public decimal Discount { get; set; } +} diff --git a/LiteCharms.Features.MidrandBooks/Products/Models/Product.cs b/LiteCharms.Features.MidrandBooks/Products/Models/Product.cs new file mode 100644 index 0000000..42a977f --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Products/Models/Product.cs @@ -0,0 +1,30 @@ +using LiteCharms.Features.Models; + +namespace LiteCharms.Features.MidrandBooks.Products.Models; + +public class Product +{ + public long Id { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public ProductTypes Type { get; set; } + + public string? Name { get; set; } + + public string? Summary { get; set; } + + public string? Description { get; set; } + + public string? ImageUrl { get; set; } + + public string[]? ThumbnailUrls { get; set; } + + public string[]? Categories { get; set; } + + public ProductMetadata? Metadata { get; set; } + + public bool Enabled { get; set; } +} diff --git a/LiteCharms.Features.MidrandBooks/Products/Models/ProductPrice.cs b/LiteCharms.Features.MidrandBooks/Products/Models/ProductPrice.cs new file mode 100644 index 0000000..a297068 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Products/Models/ProductPrice.cs @@ -0,0 +1,18 @@ +namespace LiteCharms.Features.MidrandBooks.Products.Models; + +public class ProductPrice +{ + public long Id { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public long ProductId { get; set; } + + public decimal Amount { get; set; } + + public decimal Discount { get; set; } + + public bool Enabled { get; set; } +} diff --git a/LiteCharms.Features.MidrandBooks/Products/Models/Records.cs b/LiteCharms.Features.MidrandBooks/Products/Models/Records.cs new file mode 100644 index 0000000..642a739 --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Products/Models/Records.cs @@ -0,0 +1,22 @@ +using LiteCharms.Features.Models; + +namespace LiteCharms.Features.MidrandBooks.Products.Models; + +public record CreateProduct +{ + public required ProductTypes Type { get; set; } + + public required string Name { get; set; } + + public required string Summary { get; set; } + + public required string Description { get; set; } + + public required string ImageUrl { get; set; } + + public string[]? ThumbnailUrls { get; set; } + + public string[]? Categories { get; set; } + + public ProductMetadata? Metadata { get; set; } +} diff --git a/LiteCharms.Features.MidrandBooks/Products/ProductService.cs b/LiteCharms.Features.MidrandBooks/Products/ProductService.cs new file mode 100644 index 0000000..5ee455f --- /dev/null +++ b/LiteCharms.Features.MidrandBooks/Products/ProductService.cs @@ -0,0 +1,252 @@ +using LiteCharms.Features.MidrandBooks.Extensions; +using LiteCharms.Features.MidrandBooks.Postgres; +using LiteCharms.Features.MidrandBooks.Products.Models; +using LiteCharms.Features.Models; + +namespace LiteCharms.Features.MidrandBooks.Products; + +public class ProductService(IDbContextFactory contextFactory) +{ + public async ValueTask UpdateProductPriceStatusAsync(long productPriceId, bool isEnabled, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var productPrice = await context.Prices.FirstOrDefaultAsync(p => p.Id == productPriceId, cancellationToken); + + if (productPrice is null) + return Result.Fail(new Error($"Product price with ID {productPriceId} not found")); + + productPrice.UpdatedAt = DateTime.UtcNow; + productPrice.Enabled = isEnabled; + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail(new Error($"Failed to change status of product price with ID {productPriceId}")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask UpdateProductStatusAsync(long productId, bool isEnabled, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var product = await context.Products.FirstOrDefaultAsync(p => p.Id == productId, cancellationToken); + + if (product is null) + return Result.Fail(new Error($"Product with ID {productId} not found")); + + product.UpdatedAt = DateTime.UtcNow; + product.Enabled = isEnabled; + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok() + : Result.Fail(new Error($"Failed to change status of product with ID {productId}")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> SearchProductsAsync(ProductFilter filter, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var query = context.Products.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(filter.Title)) + query = query.Where(p => p.Name!.Contains(filter.Title)); + + if (!string.IsNullOrWhiteSpace(filter.Category)) + query = query.Where(p => p.Categories!.Any(c => c == filter.Category)); + + if (!string.IsNullOrWhiteSpace(filter.Manufacturer)) + query = query.Where(p => p.Metadata!.Manufacturer == filter.Manufacturer); + + if (!string.IsNullOrWhiteSpace(filter.SerialNumber)) + query = query.Where(p => p.Metadata!.SerialNumber == filter.SerialNumber); + + if (filter.MinPrice > 0) + query = query.Where(p => p.Prices!.Any(pr => pr.Amount >= filter.MinPrice && pr.Amount <= filter.MaxPrice)); + + var products = await query.AsNoTracking().Where(p => p.Enabled).ToListAsync(cancellationToken); + + return products?.Count > 0 + ? Result.Ok(products.Select(p => p.ToModel()).ToArray()) + : Result.Fail("No products found."); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> CreateProductPriceAsync(long productId, CreateProductPrice request, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if(!await context.Products.AnyAsync(p => p.Id == productId, cancellationToken)) + return Result.Fail($"Product with ID {productId} does not exist."); + + var existingPrices = await context.Prices.Where(p => p.ProductId == productId).ToListAsync(cancellationToken); + + if (existingPrices.Count > 0) + foreach (var existingPrice in existingPrices) + { + existingPrice.Enabled = false; + existingPrice.UpdatedAt = DateTime.UtcNow; + context.Prices.Update(existingPrice); + } + + var price = context.Prices.Add(new Entities.ProductPrice + { + ProductId = productId, + Amount = request.Amount, + Discount = request.Discount, + Enabled = true, + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok(price.Entity.Id) + : Result.Fail("Failed to create product price."); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> CreateProductAsync(CreateProduct request, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + if(await context.Products.AnyAsync(p => p.Name == request.Name, cancellationToken)) + return Result.Fail("A product with the same name already exists."); + + if(request.Metadata is not null) + if (await context.Products.AnyAsync(p => p.Metadata!.SerialNumber == request.Metadata.SerialNumber, cancellationToken)) + return Result.Fail("A product with the same metadata already exists."); + + var product = context.Products.Add(new Entities.Product + { + UpdatedAt = DateTime.UtcNow, + Type = request.Type, + Name = request.Name, + Summary = request.Summary, + Description = request.Description, + ImageUrl = request.ImageUrl, + ThumbnailUrls = request.ThumbnailUrls, + Metadata = request.Metadata, + Categories = request.Categories, + Enabled = true + }); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok(product.Entity.Id) + : Result.Fail("Failed to create product."); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetProductPriceAsync(long productId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var product = await context.Prices + .AsNoTracking() + .OrderByDescending(p => p.CreatedAt) + .ThenBy(p => p.UpdatedAt) + .FirstOrDefaultAsync(p => p.ProductId == productId, cancellationToken); + + return product is not null + ? Result.Ok(new[] { product.ToModel() }) + : Result.Fail(new Error($"No price found for product with ID {productId}")); + + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetProductPricesAsync(long productId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var prices = await context.Prices.Where(p => p.ProductId == productId).ToListAsync(cancellationToken); + + return prices?.Count > 0 + ? prices.Select(p => p.ToModel()).ToArray() + : Result.Fail(new Error($"No prices found for product with ID {productId}")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetProductAsync(long productId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var product = await context.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == productId, cancellationToken); + + return product is null + ? Result.Fail(new Error($"Product with ID {productId} not found.")) + : Result.Ok(product.ToModel()); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } + + public async ValueTask> GetProductsAsync(DateRange range, CancellationToken cancellationToken = default) + { + try + { + var fromDate = range.From.ToDateTime(TimeOnly.MinValue); + var toDate = range.To.ToDateTime(TimeOnly.MaxValue); + + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var products = await context.Products + .AsNoTracking() + .OrderByDescending(p => p.CreatedAt) + .ThenBy(p => p.UpdatedAt) + .Where(p => p.CreatedAt >= fromDate && p.CreatedAt <= toDate) + .Take(range.MaxRecords) + .ToArrayAsync(cancellationToken); + + return await context.SaveChangesAsync(cancellationToken) > 0 + ? Result.Ok(products.Select(p => p.ToModel()).ToArray()) + : Result.Fail(new Error("Failed to retrieve products.")); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} diff --git a/LiteCharms.Features.TechShop/Customers/CustomerService.cs b/LiteCharms.Features.TechShop/Customers/CustomerService.cs index c8ea2cd..c4983c6 100644 --- a/LiteCharms.Features.TechShop/Customers/CustomerService.cs +++ b/LiteCharms.Features.TechShop/Customers/CustomerService.cs @@ -1,5 +1,4 @@ -using LiteCharms.Features.Extensions; -using LiteCharms.Features.Models; +using LiteCharms.Features.Models; using LiteCharms.Features.TechShop.Customers.Models; using LiteCharms.Features.TechShop.Extensions; using LiteCharms.Features.TechShop.Postgres; diff --git a/LiteCharms.Features.TechShop/Products/Models/Product.cs b/LiteCharms.Features.TechShop/Products/Models/Product.cs index b5fd71b..db9dba7 100644 --- a/LiteCharms.Features.TechShop/Products/Models/Product.cs +++ b/LiteCharms.Features.TechShop/Products/Models/Product.cs @@ -1,4 +1,6 @@ -namespace LiteCharms.Features.TechShop.Products.Models; +using LiteCharms.Features.Models; + +namespace LiteCharms.Features.TechShop.Products.Models; public class Product { diff --git a/LiteCharms.Features/Enums.cs b/LiteCharms.Features/Enums.cs index 29a915f..510d826 100644 --- a/LiteCharms.Features/Enums.cs +++ b/LiteCharms.Features/Enums.cs @@ -1,5 +1,18 @@ namespace LiteCharms.Features; +public enum SocialMediaTypes : int +{ + Twitter = 0, + Facebook = 1, + Instagram = 2, + LinkedIn = 3, + TikTok = 4, + YouTube = 5, + Pinterest = 6, + Reddit = 7, + Tumblr = 8 +} + public enum EmailStatuses : int { GeneralError = 0, diff --git a/LiteCharms.Features/Models/PageReference.cs b/LiteCharms.Features/Models/PageReference.cs new file mode 100644 index 0000000..ab04eb9 --- /dev/null +++ b/LiteCharms.Features/Models/PageReference.cs @@ -0,0 +1,10 @@ +namespace LiteCharms.Features.Models; + +public class PageReference +{ + public string? Tag { get; set; } + + public string? Description { get; set; } + + public string? Url { get; set; } +} diff --git a/LiteCharms.Features/Models/ProductFilter.cs b/LiteCharms.Features/Models/ProductFilter.cs new file mode 100644 index 0000000..c6cc275 --- /dev/null +++ b/LiteCharms.Features/Models/ProductFilter.cs @@ -0,0 +1,18 @@ +namespace LiteCharms.Features.Models; + +public class ProductFilter +{ + public string? Name { get; set; } + + public string? Title { get; set; } + + public string? Category { get; set; } + + public string? Manufacturer { get; set; } + + public string? SerialNumber { get; set; } + + public decimal MinPrice { get; set; } + + public decimal MaxPrice { get; set; } +} diff --git a/LiteCharms.Features.TechShop/Products/Models/ProductMetadata.cs b/LiteCharms.Features/Models/ProductMetadata.cs similarity index 79% rename from LiteCharms.Features.TechShop/Products/Models/ProductMetadata.cs rename to LiteCharms.Features/Models/ProductMetadata.cs index 168f2bd..ee193fe 100644 --- a/LiteCharms.Features.TechShop/Products/Models/ProductMetadata.cs +++ b/LiteCharms.Features/Models/ProductMetadata.cs @@ -1,4 +1,4 @@ -namespace LiteCharms.Features.TechShop.Products.Models; +namespace LiteCharms.Features.Models; public class ProductMetadata { diff --git a/LiteCharms.Features/Models/SocialMedia.cs b/LiteCharms.Features/Models/SocialMedia.cs new file mode 100644 index 0000000..296d979 --- /dev/null +++ b/LiteCharms.Features/Models/SocialMedia.cs @@ -0,0 +1,13 @@ + +namespace LiteCharms.Features.Models; + +public class SocialMedia +{ + public SocialMediaTypes Type { get; set; } + + public string? Name { get; set; } + + public string? ImageUrl { get; set; } + + public string? Url { get; set; } +}