diff --git a/README.md b/README.md index 7c67dcd..df98251 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,8 @@ - The following is an example, illustrating the format for the headers and for the rows containing data: ``` -Artist,Album,Genre,Released,Cover Url,Track Number,Track,Duration,Wish List -Duke Ellington,Ellington Indigoes,Jazz,1958,,1,Solitude,04:43,False +Artist,Album,Genre,Released,Cover Url,Track Number,Track,Duration,Wish List,Purchase Date,Price,Retailer +George Harrison,All Things Must Pass,Rock & Roll,1970,https://www.theaudiodb.com/images/media/album/thumb/all-things-must-pass-4f09954aa6370.jpg,1,I'd Have You Anytime,02:56,False,12/11/2023,59.07,Amazon ``` - Exports include all albums in both the main catalogue and the wish list @@ -134,12 +134,16 @@ MusicCatalogue.LookupTool --lookup "John Coltrane" "Blue Train" catalogue ### Configuration -- The UI uses a simple "config.json" file containing only one setting, the base URL for the Music Catalogue web service: +- The UI uses a simple "config.json" file containing the base URL for the Music Catalogue web service and locale settings used by the UI: ```json { "api": { "baseUrl": "http://localhost:8098" + }, + "region": { + "locale": "en-GB", + "currency": "GBP" } } ``` @@ -167,6 +171,10 @@ MusicCatalogue.LookupTool --lookup "John Coltrane" "Blue Train" catalogue - As the mouse pointer is moved up and down the table, the current row is highlighted - Clicking on the trash icon prompts for confirmation and, if confirmed, deletes the album shown in that row along with the associated tracks - Clicking on the "heart" icon moves the album from the main catalogue to the wish list then refreshes the album list +- Clicking on the "money" icon opens a form allowing the purchase details to be set: + +Purchase Details + - Clicking anywhere else on a row opens the track list for the album shown in that row: Track List @@ -184,6 +192,7 @@ MusicCatalogue.LookupTool --lookup "John Coltrane" "Blue Train" catalogue - Clicking on a row drills into the album content, as per the "Artists" page - Clicking on the trash icon prompts for confirmation and, if confirmed, deletes the album shown in that row along with the associated tracks - Clicking on the vinyl record icon moves the album from the wish list to the main catalogue then refreshes the album list +- Clicking on the money icon opens the purchase details page and allows the price and a potential retailer to be set, but not the purchase date ### Album Lookup @@ -237,6 +246,7 @@ MusicCatalogue.LookupTool --lookup "John Coltrane" "Blue Train" catalogue - Retrieving artist details from the local database - Retrieving album and track details from the local database - Looking up albums via the external APIs (see below) + - Managing retailers and purchase details - The external lookup uses the "album lookup" algorithm described under "Album Lookup", below - Swagger documentation exposed at: diff --git a/diagrams/album-list.png b/diagrams/album-list.png index e57bf96..f315786 100644 Binary files a/diagrams/album-list.png and b/diagrams/album-list.png differ diff --git a/diagrams/artist-list.png b/diagrams/artist-list.png index d3ce90d..5f5a27a 100644 Binary files a/diagrams/artist-list.png and b/diagrams/artist-list.png differ diff --git a/diagrams/database-schema.png b/diagrams/database-schema.png index 53ca515..46eaf76 100644 Binary files a/diagrams/database-schema.png and b/diagrams/database-schema.png differ diff --git a/diagrams/purchase-details.png b/diagrams/purchase-details.png new file mode 100644 index 0000000..ec45e53 Binary files /dev/null and b/diagrams/purchase-details.png differ diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 1adf669..dc45084 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -1,4 +1,4 @@ FROM mcr.microsoft.com/dotnet/core/aspnet:latest -COPY musiccatalogue.api-1.9.0.0 /opt/musiccatalogue.api-1.9.0.0 -WORKDIR /opt/musiccatalogue.api-1.9.0.0/bin +COPY musiccatalogue.api-1.10.0.0 /opt/musiccatalogue.api-1.10.0.0 +WORKDIR /opt/musiccatalogue.api-1.10.0.0/bin ENTRYPOINT [ "./MusicCatalogue.Api" ] diff --git a/docker/ui/Dockerfile b/docker/ui/Dockerfile index afcaa6b..3f15314 100644 --- a/docker/ui/Dockerfile +++ b/docker/ui/Dockerfile @@ -1,6 +1,6 @@ FROM node:20-alpine -COPY musiccatalogue.ui-1.9.0.0 /opt/musiccatalogue.ui-1.9.0.0 -WORKDIR /opt/musiccatalogue.ui-1.9.0.0 +COPY musiccatalogue.ui-1.10.0.0 /opt/musiccatalogue.ui-1.10.0.0 +WORKDIR /opt/musiccatalogue.ui-1.10.0.0 RUN npm install RUN npm run build ENTRYPOINT [ "npm", "start" ] diff --git a/src/MusicCatalogue.Api/Controllers/AlbumsController.cs b/src/MusicCatalogue.Api/Controllers/AlbumsController.cs index db11b8d..a4ba507 100644 --- a/src/MusicCatalogue.Api/Controllers/AlbumsController.cs +++ b/src/MusicCatalogue.Api/Controllers/AlbumsController.cs @@ -85,7 +85,10 @@ public async Task> UpdateAlbumAsync([FromBody] Album templat template.Released, template.Genre, template.CoverUrl, - template.IsWishListItem); + template.IsWishListItem, + template.Purchased, + template.Price, + template.RetailerId); // If the result is NULL, the album doesn't exist if (album == null) diff --git a/src/MusicCatalogue.Api/Controllers/ExportController.cs b/src/MusicCatalogue.Api/Controllers/ExportController.cs index 19f0920..18e76fd 100644 --- a/src/MusicCatalogue.Api/Controllers/ExportController.cs +++ b/src/MusicCatalogue.Api/Controllers/ExportController.cs @@ -20,7 +20,7 @@ public ExportController(IBackgroundQueue catalogueQueue [HttpPost] [Route("catalogue")] - public IActionResult ExportSightings([FromBody] CatalogueExportWorkItem item) + public IActionResult Export([FromBody] CatalogueExportWorkItem item) { // Set the job name used in the job status record item.JobName = "Catalogue Export"; diff --git a/src/MusicCatalogue.Api/Controllers/RetailersController.cs b/src/MusicCatalogue.Api/Controllers/RetailersController.cs new file mode 100644 index 0000000..90bf3a2 --- /dev/null +++ b/src/MusicCatalogue.Api/Controllers/RetailersController.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MusicCatalogue.Entities.Database; +using MusicCatalogue.Entities.Exceptions; +using MusicCatalogue.Entities.Interfaces; + +namespace MusicCatalogue.Api.Controllers +{ + [Authorize] + [ApiController] + [ApiConventionType(typeof(DefaultApiConventions))] + [Route("[controller]")] + public class RetailersController : Controller + { + private readonly IMusicCatalogueFactory _factory; + + public RetailersController(IMusicCatalogueFactory factory) + { + _factory = factory; + } + + /// + /// Return a list of all the retailers in the catalogue + /// + /// + [HttpGet] + [Route("")] + public async Task>> GetRetailersAsync() + { + // Get a list of all artists in the catalogue + List retailers = await _factory.Retailers.ListAsync(x => true); + + // If there are no artists, return a no content response + if (!retailers.Any()) + { + return NoContent(); + } + + return retailers; + } + + /// + /// Return retailer details given a retailer ID + /// + /// + /// + [HttpGet] + [Route("{id:int}")] + public async Task> GetRetailerByIdAsync(int id) + { + var retailer = await _factory.Retailers.GetAsync(x => x.Id == id); + + if (retailer == null) + { + return NotFound(); + } + + return retailer; + } + + /// + /// Add a retailer to the catalogue + /// + /// + /// + [HttpPost] + [Route("")] + public async Task> AddRetailerAsync([FromBody] Retailer template) + { + var retailer = await _factory.Retailers.AddAsync(template.Name); + return retailer; + } + + /// + /// Update an existing retailer + /// + /// + /// + [HttpPut] + [Route("")] + public async Task> UpdateRetailerAsync([FromBody] Retailer template) + { + var retailer = await _factory.Retailers.UpdateAsync(template.Id, template.Name); + return retailer; + } + + /// + /// Delete an existing retailer + /// + /// + /// + [HttpDelete] + [Route("{id}")] + public async Task DeleteRetailerAsync(int id) + { + // Make sure the retailer exists + var retailer = await _factory.Retailers.GetAsync(x => x.Id == id); + + // If the retailer doesn't exist, return a 404 + if (retailer == null) + { + return NotFound(); + } + + try + { + // Delete the retailer + await _factory.Retailers.DeleteAsync(id); + } + catch (RetailerInUseException) + { + // Retailer is in use so this is a bad request + return BadRequest(); + } + + return Ok(); + } + } +} \ No newline at end of file diff --git a/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj b/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj index 45e3667..a84ac55 100644 --- a/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj +++ b/src/MusicCatalogue.Api/MusicCatalogue.Api.csproj @@ -2,9 +2,9 @@ net7.0 - 1.9.0.0 - 1.9.0.0 - 1.9.0 + 1.10.0.0 + 1.10.0.0 + 1.10.0 enable enable diff --git a/src/MusicCatalogue.Data/Migrations/20231112081058_Spending.Designer.cs b/src/MusicCatalogue.Data/Migrations/20231112081058_Spending.Designer.cs new file mode 100644 index 0000000..b6d22dd --- /dev/null +++ b/src/MusicCatalogue.Data/Migrations/20231112081058_Spending.Designer.cs @@ -0,0 +1,234 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MusicCatalogue.Data; + +#nullable disable + +namespace MusicCatalogue.Data.Migrations +{ + [DbContext(typeof(MusicCatalogueDbContext))] + [Migration("20231112081058_Spending")] + partial class Spending + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); + + modelBuilder.Entity("MusicCatalogue.Entities.Database.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("Id"); + + b.Property("ArtistId") + .HasColumnType("INTEGER") + .HasColumnName("ArtistId"); + + b.Property("CoverUrl") + .HasColumnType("TEXT") + .HasColumnName("CoverUrl"); + + b.Property("Genre") + .HasColumnType("TEXT") + .HasColumnName("Genre"); + + b.Property("IsWishListItem") + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("TEXT") + .HasColumnName("Price"); + + b.Property("Purchased") + .HasColumnType("TEXT") + .HasColumnName("Purchased"); + + b.Property("Released") + .HasColumnType("INTEGER") + .HasColumnName("Released"); + + b.Property("RetailerId") + .HasColumnType("INTEGER") + .HasColumnName("RetailerId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Title"); + + b.HasKey("Id"); + + b.HasIndex("ArtistId"); + + b.HasIndex("RetailerId"); + + b.ToTable("ALBUMS", (string)null); + }); + + modelBuilder.Entity("MusicCatalogue.Entities.Database.Artist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("Id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Name"); + + b.HasKey("Id"); + + b.ToTable("ARTISTS", (string)null); + }); + + modelBuilder.Entity("MusicCatalogue.Entities.Database.JobStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("End") + .HasColumnType("DATETIME") + .HasColumnName("end"); + + b.Property("Error") + .HasColumnType("TEXT") + .HasColumnName("error"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Parameters") + .HasColumnType("TEXT") + .HasColumnName("parameters"); + + b.Property("Start") + .HasColumnType("DATETIME") + .HasColumnName("start"); + + b.HasKey("Id"); + + b.ToTable("JOB_STATUS", (string)null); + }); + + modelBuilder.Entity("MusicCatalogue.Entities.Database.Retailer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("Id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Name"); + + b.HasKey("Id"); + + b.ToTable("RETAILERS", (string)null); + }); + + modelBuilder.Entity("MusicCatalogue.Entities.Database.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("Id"); + + b.Property("AlbumId") + .HasColumnType("INTEGER") + .HasColumnName("AlbumId"); + + b.Property("Duration") + .HasColumnType("INTEGER") + .HasColumnName("Duration"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("Number"); + + b.Property("Purchased") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Title"); + + b.HasKey("Id"); + + b.HasIndex("AlbumId"); + + b.ToTable("TRACKS", (string)null); + }); + + modelBuilder.Entity("MusicCatalogue.Entities.Database.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Password"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("UserName"); + + b.HasKey("Id"); + + b.ToTable("USER", (string)null); + }); + + modelBuilder.Entity("MusicCatalogue.Entities.Database.Album", b => + { + b.HasOne("MusicCatalogue.Entities.Database.Artist", null) + .WithMany("Albums") + .HasForeignKey("ArtistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MusicCatalogue.Entities.Database.Retailer", "Retailer") + .WithMany() + .HasForeignKey("RetailerId"); + + b.Navigation("Retailer"); + }); + + modelBuilder.Entity("MusicCatalogue.Entities.Database.Track", b => + { + b.HasOne("MusicCatalogue.Entities.Database.Album", null) + .WithMany("Tracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MusicCatalogue.Entities.Database.Album", b => + { + b.Navigation("Tracks"); + }); + + modelBuilder.Entity("MusicCatalogue.Entities.Database.Artist", b => + { + b.Navigation("Albums"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MusicCatalogue.Data/Migrations/20231112081058_Spending.cs b/src/MusicCatalogue.Data/Migrations/20231112081058_Spending.cs new file mode 100644 index 0000000..91368eb --- /dev/null +++ b/src/MusicCatalogue.Data/Migrations/20231112081058_Spending.cs @@ -0,0 +1,97 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MusicCatalogue.Data.Migrations +{ + [ExcludeFromCodeCoverage] + /// + public partial class Spending : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Purchased", + table: "TRACKS", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Price", + table: "ALBUMS", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Purchased", + table: "ALBUMS", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "RetailerId", + table: "ALBUMS", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "RETAILERS", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RETAILERS", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ALBUMS_RetailerId", + table: "ALBUMS", + column: "RetailerId"); + + migrationBuilder.AddForeignKey( + name: "FK_ALBUMS_RETAILERS_RetailerId", + table: "ALBUMS", + column: "RetailerId", + principalTable: "RETAILERS", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ALBUMS_RETAILERS_RetailerId", + table: "ALBUMS"); + + migrationBuilder.DropTable( + name: "RETAILERS"); + + migrationBuilder.DropIndex( + name: "IX_ALBUMS_RetailerId", + table: "ALBUMS"); + + migrationBuilder.DropColumn( + name: "Purchased", + table: "TRACKS"); + + migrationBuilder.DropColumn( + name: "Price", + table: "ALBUMS"); + + migrationBuilder.DropColumn( + name: "Purchased", + table: "ALBUMS"); + + migrationBuilder.DropColumn( + name: "RetailerId", + table: "ALBUMS"); + } + } +} diff --git a/src/MusicCatalogue.Data/Migrations/MusicCatalogueDbContextModelSnapshot.cs b/src/MusicCatalogue.Data/Migrations/MusicCatalogueDbContextModelSnapshot.cs index 9c95491..c82fe4b 100644 --- a/src/MusicCatalogue.Data/Migrations/MusicCatalogueDbContextModelSnapshot.cs +++ b/src/MusicCatalogue.Data/Migrations/MusicCatalogueDbContextModelSnapshot.cs @@ -41,10 +41,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsWishListItem") .HasColumnType("INTEGER"); + b.Property("Price") + .HasColumnType("TEXT") + .HasColumnName("Price"); + + b.Property("Purchased") + .HasColumnType("TEXT") + .HasColumnName("Purchased"); + b.Property("Released") .HasColumnType("INTEGER") .HasColumnName("Released"); + b.Property("RetailerId") + .HasColumnType("INTEGER") + .HasColumnName("RetailerId"); + b.Property("Title") .IsRequired() .HasColumnType("TEXT") @@ -54,6 +66,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ArtistId"); + b.HasIndex("RetailerId"); + b.ToTable("ALBUMS", (string)null); }); @@ -107,6 +121,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("JOB_STATUS", (string)null); }); + modelBuilder.Entity("MusicCatalogue.Entities.Database.Retailer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("Id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Name"); + + b.HasKey("Id"); + + b.ToTable("RETAILERS", (string)null); + }); + modelBuilder.Entity("MusicCatalogue.Entities.Database.Track", b => { b.Property("Id") @@ -126,6 +157,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("INTEGER") .HasColumnName("Number"); + b.Property("Purchased") + .HasColumnType("TEXT"); + b.Property("Title") .IsRequired() .HasColumnType("TEXT") @@ -167,6 +201,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("ArtistId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + + b.HasOne("MusicCatalogue.Entities.Database.Retailer", "Retailer") + .WithMany() + .HasForeignKey("RetailerId"); + + b.Navigation("Retailer"); }); modelBuilder.Entity("MusicCatalogue.Entities.Database.Track", b => diff --git a/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj b/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj index 9c5c266..c32bc04 100644 --- a/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj +++ b/src/MusicCatalogue.Data/MusicCatalogue.Data.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Data - 1.9.0.0 + 1.10.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,7 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.9.0.0 + 1.10.0.0 diff --git a/src/MusicCatalogue.Data/MusicCatalogueDbContext.cs b/src/MusicCatalogue.Data/MusicCatalogueDbContext.cs index 05101f6..b337629 100644 --- a/src/MusicCatalogue.Data/MusicCatalogueDbContext.cs +++ b/src/MusicCatalogue.Data/MusicCatalogueDbContext.cs @@ -10,6 +10,7 @@ public class MusicCatalogueDbContext : DbContext public virtual DbSet Artists { get; set; } public virtual DbSet Albums { get; set; } public virtual DbSet Tracks { get; set; } + public virtual DbSet Retailers { get; set; } public virtual DbSet Users { get; set; } public virtual DbSet JobStatuses { get; set; } @@ -35,6 +36,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Password).IsRequired().HasColumnName("Password"); }); + modelBuilder.Entity(entity => + { + entity.ToTable("RETAILERS"); + + entity.Property(e => e.Id).HasColumnName("Id").ValueGeneratedOnAdd(); + entity.Property(e => e.Name).IsRequired().HasColumnName("Name"); + }); + modelBuilder.Entity(entity => { entity.ToTable("ARTISTS"); @@ -43,6 +52,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Name).IsRequired().HasColumnName("Name"); entity.Ignore(e => e.AlbumCount); entity.Ignore(e => e.TrackCount); + entity.Ignore(e => e.TotalAlbumSpend); }); modelBuilder.Entity(entity => @@ -55,6 +65,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Released).HasColumnName("Released"); entity.Property(e => e.Genre).HasColumnName("Genre"); entity.Property(e => e.CoverUrl).HasColumnName("CoverUrl"); + entity.Property(e => e.Purchased).HasColumnName("Purchased"); + entity.Property(e => e.Price).HasColumnName("Price"); + entity.Property(e => e.RetailerId).HasColumnName("RetailerId"); }); modelBuilder.Entity(entity => diff --git a/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs b/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs index 4699fa4..81d23e2 100644 --- a/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs +++ b/src/MusicCatalogue.Entities/DataExchange/FlattenedTrack.cs @@ -17,6 +17,10 @@ public class FlattenedTrack : TrackBase private const int TitleField = 6; private const int DurationField = 7; private const int WishlistItemField = 8; + private const int PurchasedField = 9; + private const int PriceField = 10; + private const int RetailerField = 11; + private const int NumberOfFields = 12; public string ArtistName{ get; set; } = ""; public string AlbumTitle { get; set; } = ""; @@ -27,12 +31,19 @@ public class FlattenedTrack : TrackBase public string Title { get; set; } = ""; public bool? IsWishlistItem { get; set; } + public decimal? Price { get; set; } + public string? RetailerName { get; set; } = ""; + /// /// Create a representation of the flattened track in CSV format /// /// public string ToCsv() { + var wishListString = (IsWishlistItem ?? false).ToString(); + var purchasedDateString = Purchased != null ? (Purchased ?? DateTime.Now).ToString(DateTimeFormat) : ""; + var priceString = Price != null ? Price.ToString() : ""; + StringBuilder builder = new StringBuilder(); AppendField(builder, ArtistName); AppendField(builder, AlbumTitle); @@ -42,7 +53,11 @@ public string ToCsv() AppendField(builder, TrackNumber); AppendField(builder, Title); AppendField(builder, FormattedDuration); - AppendField(builder, (IsWishlistItem ?? false).ToString()); + AppendField(builder, wishListString); + AppendField(builder, purchasedDateString); + AppendField(builder, priceString); + AppendField(builder, RetailerName ?? ""); + return builder.ToString(); } @@ -54,7 +69,7 @@ public string ToCsv() public static FlattenedTrack FromCsv(IList fields) { // Check we have the required number of fields - if ((fields == null) || (fields.Count != 9)) + if ((fields == null) || (fields.Count != NumberOfFields)) { throw new InvalidRecordFormatException("Incorrect number of CSV fields"); } @@ -66,7 +81,17 @@ public static FlattenedTrack FromCsv(IList fields) // Split the duration on the ":" separator and convert to milliseconds var durationWords = fields[DurationField].Split(new string[] { ":" }, StringSplitOptions.None); var durationMs = 1000 * (60 * int.Parse(durationWords[0]) + int.Parse(durationWords[1])); - + + // Determine the purchase date + DateTime? purchasedDate = null; + if (!string.IsNullOrEmpty(fields[PurchasedField])) + { + purchasedDate = DateTime.ParseExact(fields[PurchasedField], DateTimeFormat, null); + } + + // Determine the price + decimal? price = !string.IsNullOrEmpty(fields[PriceField]) ? decimal.Parse(fields[PriceField]) : null; + // Create a new "flattened" record containing artist, album and track details return new FlattenedTrack { @@ -78,7 +103,10 @@ public static FlattenedTrack FromCsv(IList fields) TrackNumber = int.Parse(fields[TrackNumberField]), Title = fields[TitleField], Duration = durationMs, - IsWishlistItem = bool.Parse(fields[WishlistItemField]) + IsWishlistItem = bool.Parse(fields[WishlistItemField]), + Purchased = purchasedDate, + Price = price, + RetailerName = fields[RetailerField] }; } diff --git a/src/MusicCatalogue.Entities/Database/Album.cs b/src/MusicCatalogue.Entities/Database/Album.cs index 7da5c48..a6f7424 100644 --- a/src/MusicCatalogue.Entities/Database/Album.cs +++ b/src/MusicCatalogue.Entities/Database/Album.cs @@ -24,8 +24,16 @@ public class Album public bool? IsWishListItem { get; set; } + public DateTime? Purchased { get; set; } + + public decimal? Price { get; set; } + + public int? RetailerId { get; set; } + #pragma warning disable CS8618 - public ICollection Tracks { get; set; } + public Retailer? Retailer { get; set; } + + public ICollection? Tracks { get; set; } #pragma warning restore CS8618 } } diff --git a/src/MusicCatalogue.Entities/Database/Artist.cs b/src/MusicCatalogue.Entities/Database/Artist.cs index b1f1a5b..d14c540 100644 --- a/src/MusicCatalogue.Entities/Database/Artist.cs +++ b/src/MusicCatalogue.Entities/Database/Artist.cs @@ -16,6 +16,8 @@ public class Artist public int? TrackCount { get; set; } + public decimal TotalAlbumSpend { get; set; } + #pragma warning disable CS8618 public ICollection Albums { get; set; } #pragma warning restore CS8618 diff --git a/src/MusicCatalogue.Entities/Database/Retailer.cs b/src/MusicCatalogue.Entities/Database/Retailer.cs new file mode 100644 index 0000000..dc4635d --- /dev/null +++ b/src/MusicCatalogue.Entities/Database/Retailer.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; + +namespace MusicCatalogue.Entities.Database +{ + [ExcludeFromCodeCoverage] + public class Retailer + { + [Key] + public int Id { get; set; } + + [Required] + public string Name { get; set; } = ""; + } +} diff --git a/src/MusicCatalogue.Entities/Database/TrackBase.cs b/src/MusicCatalogue.Entities/Database/TrackBase.cs index 905c0cd..331e6e5 100644 --- a/src/MusicCatalogue.Entities/Database/TrackBase.cs +++ b/src/MusicCatalogue.Entities/Database/TrackBase.cs @@ -1,11 +1,15 @@ using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; namespace MusicCatalogue.Entities.Database { [ExcludeFromCodeCoverage] public abstract class TrackBase { + protected const string DateTimeFormat = "dd/MM/yyyy"; + public int? Duration { get; set; } + public string? FormattedDuration { get @@ -23,5 +27,15 @@ public string? FormattedDuration } } } + + public DateTime? Purchased { get; set; } + + public string FormattedPurchaseDate + { + get + { + return Purchased != null ? (Purchased ?? DateTime.Now).ToString(DateTimeFormat) : ""; + } + } } } diff --git a/src/MusicCatalogue.Entities/Exceptions/RetailerInUseException.cs b/src/MusicCatalogue.Entities/Exceptions/RetailerInUseException.cs new file mode 100644 index 0000000..5ad1d0b --- /dev/null +++ b/src/MusicCatalogue.Entities/Exceptions/RetailerInUseException.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace MusicCatalogue.Entities.Exceptions +{ + + [Serializable] + [ExcludeFromCodeCoverage] + public class RetailerInUseException : Exception + { + public RetailerInUseException() + { + } + + public RetailerInUseException(string message) : base(message) + { + } + + public RetailerInUseException(string message, Exception inner) : base(message, inner) + { + } + + protected RetailerInUseException(SerializationInfo serializationInfo, StreamingContext streamingContext) : base(serializationInfo, streamingContext) + { + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + } + } +} \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/Interfaces/IAlbumManager.cs b/src/MusicCatalogue.Entities/Interfaces/IAlbumManager.cs index 2907d62..c62ee24 100644 --- a/src/MusicCatalogue.Entities/Interfaces/IAlbumManager.cs +++ b/src/MusicCatalogue.Entities/Interfaces/IAlbumManager.cs @@ -5,8 +5,29 @@ namespace MusicCatalogue.Entities.Interfaces { public interface IAlbumManager { - Task AddAsync(int artistId, string title, int? released, string? genre, string? coverUrl, bool? isWishlistItem); - Task UpdateAsync(int albumId, int artistId, string title, int? released, string? genre, string? coverUrl, bool? isWishlistItem); + Task AddAsync( + int artistId, + string title, + int? released, + string? genre, + string? coverUrl, + bool? isWishlistItem, + DateTime? purchased, + decimal? price, + int? retailerId); + + Task UpdateAsync( + int albumId, + int artistId, + string title, + int? released, + string? genre, + string? coverUrl, + bool? isWishlistItem, + DateTime? purchased, + decimal? price, + int? retailerId); + Task GetAsync(Expression> predicate); Task> ListAsync(Expression> predicate); Task DeleteAsync(int albumId); diff --git a/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs b/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs index d521a45..80ba1f3 100644 --- a/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs +++ b/src/MusicCatalogue.Entities/Interfaces/IMusicCatalogueFactory.cs @@ -8,6 +8,7 @@ public interface IMusicCatalogueFactory IAlbumManager Albums { get; } IArtistManager Artists { get; } ITrackManager Tracks { get; } + IRetailerManager Retailers { get; } IUserManager Users { get; } IImporter Importer { get; } IExporter CsvExporter { get; } diff --git a/src/MusicCatalogue.Entities/Interfaces/IRetailerManager.cs b/src/MusicCatalogue.Entities/Interfaces/IRetailerManager.cs new file mode 100644 index 0000000..6feda06 --- /dev/null +++ b/src/MusicCatalogue.Entities/Interfaces/IRetailerManager.cs @@ -0,0 +1,14 @@ +using MusicCatalogue.Entities.Database; +using System.Linq.Expressions; + +namespace MusicCatalogue.Entities.Interfaces +{ + public interface IRetailerManager + { + Task AddAsync(string name); + Task GetAsync(Expression> predicate); + Task> ListAsync(Expression> predicate); + Task UpdateAsync(int retailerId, string name); + Task DeleteAsync(int retailerId); + } +} \ No newline at end of file diff --git a/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj b/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj index 0673b77..1ca7338 100644 --- a/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj +++ b/src/MusicCatalogue.Entities/MusicCatalogue.Entities.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Entities - 1.9.0.0 + 1.10.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,7 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.9.0.0 + 1.10.0.0 diff --git a/src/MusicCatalogue.Logic/Collection/AlbumLookupManager.cs b/src/MusicCatalogue.Logic/Collection/AlbumLookupManager.cs index 9aff6d2..c73dc75 100644 --- a/src/MusicCatalogue.Logic/Collection/AlbumLookupManager.cs +++ b/src/MusicCatalogue.Logic/Collection/AlbumLookupManager.cs @@ -81,7 +81,16 @@ private async Task StoreAlbumLocally(string artistName, Album template, b var artist = await _factory.Artists.AddAsync(artistName); // Save the album details - var album = await _factory.Albums.AddAsync(artist.Id, template.Title, template.Released, template.Genre, template.CoverUrl, storeAlbumInWishlist); + var album = await _factory.Albums.AddAsync( + artist.Id, + template.Title, + template.Released, + template.Genre, + template.CoverUrl, + storeAlbumInWishlist, + template.Purchased, + template.Price, + template.RetailerId); // Save the track details foreach (var track in template.Tracks!) diff --git a/src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs b/src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs index e392090..abd1689 100644 --- a/src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs +++ b/src/MusicCatalogue.Logic/DataExchange/CsvExporter.cs @@ -1,6 +1,5 @@ using MusicCatalogue.Entities.DataExchange; using MusicCatalogue.Entities.Interfaces; -using System.Diagnostics.CodeAnalysis; using System.Text; namespace MusicCatalogue.Logic.DataExchange diff --git a/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs b/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs index 5ded9d6..eda4e39 100644 --- a/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs +++ b/src/MusicCatalogue.Logic/DataExchange/CsvImporter.cs @@ -48,8 +48,22 @@ public async Task Import(string file) var track = FlattenedTrack.FromCsv(fields!); var artist = await _factory.Artists.AddAsync(track.ArtistName); - // See if the album exists - var album = await _factory.Albums.AddAsync(artist.Id, track.AlbumTitle, track.Released, track.Genre, track.CoverUrl, track.IsWishlistItem); + // Add the retailer + var retailer = await _factory.Retailers.AddAsync(track.RetailerName); + + // Add the album + var album = await _factory.Albums.AddAsync( + artist.Id, + track.AlbumTitle, + track.Released, + track.Genre, + track.CoverUrl, + track.IsWishlistItem, + track.Purchased, + track.Price, + retailer.Id); + + // Add the track await _factory.Tracks.AddAsync(album.Id, track.Title, track.TrackNumber, track.Duration); TrackImport?.Invoke(this, new TrackDataExchangeEventArgs { RecordCount = count - 1, Track = track }); diff --git a/src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs b/src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs index 851fb4b..759fbcf 100644 --- a/src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs +++ b/src/MusicCatalogue.Logic/DataExchange/DataExportBase.cs @@ -16,7 +16,10 @@ public abstract class DataExportBase : DataExchangeBase "Track Number", "Track", "Duration", - "Wish List" + "Wish List", + "Purchase Date", + "Price", + "Retailer" }; public event EventHandler? TrackExport; @@ -73,7 +76,10 @@ protected async Task IterateOverCollection() TrackNumber = track.Number, Title = track.Title, Duration = track.Duration, - IsWishlistItem = album.IsWishListItem + IsWishlistItem = album.IsWishListItem, + Purchased = album.Purchased, + Price = album.Price, + RetailerName = album.Retailer?.Name }; // Call the method to add this track to the file diff --git a/src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs b/src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs index 799bff0..f021790 100644 --- a/src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs +++ b/src/MusicCatalogue.Logic/DataExchange/XlsxExporter.cs @@ -1,7 +1,6 @@ using ClosedXML.Excel; using MusicCatalogue.Entities.DataExchange; using MusicCatalogue.Entities.Interfaces; -using System.Diagnostics.CodeAnalysis; namespace MusicCatalogue.Logic.DataExchange { @@ -70,6 +69,9 @@ protected override void AddTrack(FlattenedTrack track, int recordCount) _worksheet!.Cell(row, 7).Value = track.Title ?? ""; _worksheet!.Cell(row, 8).Value = track.FormattedDuration ?? ""; _worksheet!.Cell(row, 9).Value = (track.IsWishlistItem ?? false).ToString(); + _worksheet!.Cell(row, 10).Value = track.FormattedPurchaseDate; + _worksheet!.Cell(row, 11).Value = track.Price != null ? track.Price.ToString() : ""; + _worksheet!.Cell(row, 12).Value = track.RetailerName ?? ""; } } } diff --git a/src/MusicCatalogue.Logic/Database/AlbumManager.cs b/src/MusicCatalogue.Logic/Database/AlbumManager.cs index b1c42d9..1e2980f 100644 --- a/src/MusicCatalogue.Logic/Database/AlbumManager.cs +++ b/src/MusicCatalogue.Logic/Database/AlbumManager.cs @@ -41,6 +41,7 @@ public async Task> ListAsync(Expression> predicate => await _context!.Albums .Where(predicate) .OrderBy(x => x.Title) + .Include(x => x.Retailer) .Include(x => x.Tracks) .ToListAsync(); @@ -53,8 +54,20 @@ public async Task> ListAsync(Expression> predicate /// /// /// + /// + /// + /// /// - public async Task AddAsync(int artistId, string title, int? released, string? genre, string? coverUrl, bool? isWishlistItem) + public async Task AddAsync( + int artistId, + string title, + int? released, + string? genre, + string? coverUrl, + bool? isWishlistItem, + DateTime? purchased, + decimal? price, + int? retailerId) { var clean = StringCleaner.Clean(title)!; var album = await GetAsync(a => (a.ArtistId == artistId) && (a.Title == clean)); @@ -68,7 +81,10 @@ public async Task AddAsync(int artistId, string title, int? released, str Released = released, Genre = StringCleaner.RemoveInvalidCharacters(genre), CoverUrl = StringCleaner.RemoveInvalidCharacters(coverUrl), - IsWishListItem = isWishlistItem + IsWishListItem = isWishlistItem, + Purchased = purchased, + Price = price, + RetailerId = retailerId }; await _context!.Albums.AddAsync(album); await _context.SaveChangesAsync(); @@ -87,20 +103,41 @@ public async Task AddAsync(int artistId, string title, int? released, str /// /// /// + /// + /// + /// /// - public async Task UpdateAsync(int albumId, int artistId, string title, int? released, string? genre, string? coverUrl, bool? isWishlistItem) + public async Task UpdateAsync( + int albumId, + int artistId, + string title, + int? released, + string? genre, + string? coverUrl, + bool? isWishlistItem, + DateTime? purchased, + decimal? price, + int? retailerId) { var album = await GetAsync(x => x.Id == albumId); if (album != null) { + // Apply the changes album.ArtistId = artistId; album.Title = StringCleaner.Clean(title)!; album.Released = released; album.Genre = StringCleaner.RemoveInvalidCharacters(genre); album.CoverUrl = StringCleaner.RemoveInvalidCharacters(coverUrl); album.IsWishListItem = isWishlistItem; + album.Purchased = purchased; + album.Price = price; + album.RetailerId = retailerId; + // Save the changes await _context!.SaveChangesAsync(); + + // Reload the album to reflect changes in e.g. retailer + album = await GetAsync(x => x.Id == albumId); } return album; diff --git a/src/MusicCatalogue.Logic/Database/ArtistManager.cs b/src/MusicCatalogue.Logic/Database/ArtistManager.cs index 980d95e..db6bfe5 100644 --- a/src/MusicCatalogue.Logic/Database/ArtistManager.cs +++ b/src/MusicCatalogue.Logic/Database/ArtistManager.cs @@ -36,6 +36,7 @@ public async Task> ListAsync(Expression> predica .Where(predicate) .OrderBy(x => x.Name) .Include(x => x.Albums) + .ThenInclude(x => x.Retailer) .ToListAsync(); /// diff --git a/src/MusicCatalogue.Logic/Database/RetailerManager.cs b/src/MusicCatalogue.Logic/Database/RetailerManager.cs new file mode 100644 index 0000000..19f3846 --- /dev/null +++ b/src/MusicCatalogue.Logic/Database/RetailerManager.cs @@ -0,0 +1,112 @@ +using Microsoft.EntityFrameworkCore; +using MusicCatalogue.Data; +using MusicCatalogue.Entities.Database; +using MusicCatalogue.Entities.Exceptions; +using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Logic.Factory; +using System.Linq.Expressions; + +namespace MusicCatalogue.Logic.Database +{ + internal class RetailerManager : IRetailerManager + { + private readonly MusicCatalogueFactory _factory; + private readonly MusicCatalogueDbContext? _context; + + internal RetailerManager(MusicCatalogueFactory factory) + { + _factory = factory; + _context = factory.Context as MusicCatalogueDbContext; + } + + /// + /// Return the first retailer matching the specified criteria + /// + /// + /// + public async Task GetAsync(Expression> predicate) + { + List retailers = await ListAsync(predicate); + +#pragma warning disable CS8603 + return retailers.FirstOrDefault(); +#pragma warning restore CS8603 + } + + /// + /// Return all retailers matching the specified criteria + /// + /// + /// + public async Task> ListAsync(Expression> predicate) + => await _context!.Retailers + .Where(predicate) + .OrderBy(x => x.Name) + .ToListAsync(); + + /// + /// Add a retailer, if they doesn't already exist + /// + /// + /// + public async Task AddAsync(string name) + { + var clean = StringCleaner.Clean(name)!; + var retailer = await GetAsync(a => a.Name == clean); + + if (retailer == null) + { + retailer = new Retailer + { + Name = clean + }; + await _context!.Retailers.AddAsync(retailer); + await _context!.SaveChangesAsync(); + } + + return retailer; + } + + /// + /// Update a retailer given their ID + /// + /// + /// + /// + public async Task UpdateAsync(int retailerId, string name) + { + var retailer = await GetAsync(a => a.Id == retailerId); + if (retailer != null) + { + retailer.Name = StringCleaner.Clean(name)!; + await _context!.SaveChangesAsync(); + } + return retailer; + } + + /// + /// Delete a retailer given their ID + /// + /// + /// + public async Task DeleteAsync(int retailerId) + { + // Check the retailer exists + var retailer = await GetAsync(a => a.Id == retailerId); + if (retailer != null) + { + // Check the retailer isn't in use + var albums = await _factory.Albums.ListAsync(x => x.RetailerId == retailerId); + if (albums.Any()) + { + var message = $"Retailer '{retailer.Name} with Id {retailerId} is in use and cannot be deleted"; + throw new RetailerInUseException(message); + } + + // Delete the retailer + _context!.Retailers.Remove(retailer); + await _context.SaveChangesAsync(); + } + } + } +} diff --git a/src/MusicCatalogue.Logic/Database/StatisticsManager.cs b/src/MusicCatalogue.Logic/Database/StatisticsManager.cs index 1aa1743..8865868 100644 --- a/src/MusicCatalogue.Logic/Database/StatisticsManager.cs +++ b/src/MusicCatalogue.Logic/Database/StatisticsManager.cs @@ -38,6 +38,7 @@ public async Task PopulateArtistStatistics(IEnumerable artists, bool wis // Count the albums and tracks artist.AlbumCount = albums!.Count; artist.TrackCount = (albums.Count > 0) ? albums.Sum(x => x.Tracks.Count) : 0; + artist.TotalAlbumSpend = (albums.Count > 0) ? albums.Sum(x => x.Price ?? 0M) : 0; } } } diff --git a/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs b/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs index da3d7c4..78f63de 100644 --- a/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs +++ b/src/MusicCatalogue.Logic/Factory/MusicCatalogueFactory.cs @@ -12,6 +12,7 @@ public class MusicCatalogueFactory : IMusicCatalogueFactory private readonly Lazy _artists; private readonly Lazy _albums; private readonly Lazy _tracks; + private readonly Lazy _retailers; private readonly Lazy _users; private readonly Lazy _importer; private readonly Lazy _csvExporter; @@ -23,6 +24,7 @@ public class MusicCatalogueFactory : IMusicCatalogueFactory public IArtistManager Artists { get { return _artists.Value; } } public IAlbumManager Albums { get { return _albums.Value; } } public ITrackManager Tracks { get { return _tracks.Value; } } + public IRetailerManager Retailers { get { return _retailers.Value; } } public IJobStatusManager JobStatuses { get { return _jobStatuses.Value; } } public IUserManager Users { get { return _users.Value; } } @@ -42,6 +44,7 @@ public MusicCatalogueFactory(MusicCatalogueDbContext context) _artists = new Lazy(() => new ArtistManager(context)); _albums = new Lazy(() => new AlbumManager(this)); _tracks = new Lazy(() => new TrackManager(context)); + _retailers = new Lazy(() => new RetailerManager(this)); _jobStatuses = new Lazy(() => new JobStatusManager(context)); _users = new Lazy(() => new UserManager(context)); _importer = new Lazy(() => new CsvImporter(this)); diff --git a/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj b/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj index 1a9b9a1..b595b4e 100644 --- a/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj +++ b/src/MusicCatalogue.Logic/MusicCatalogue.Logic.csproj @@ -5,7 +5,7 @@ enable enable MusicCatalogue.Logic - 1.9.0.0 + 1.10.0.0 Dave Walker Copyright (c) Dave Walker 2023 Dave Walker @@ -17,7 +17,7 @@ https://github.com/davewalker5/MusicCatalogue MIT false - 1.9.0.0 + 1.10.0.0 diff --git a/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj b/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj index 5db0e0e..b25c539 100644 --- a/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj +++ b/src/MusicCatalogue.LookupTool/MusicCatalogue.LookupTool.csproj @@ -3,9 +3,9 @@ Exe net7.0 - 1.9.0.0 - 1.9.0.0 - 1.9.0 + 1.10.0.0 + 1.10.0.0 + 1.10.0 enable enable false diff --git a/src/MusicCatalogue.Tests/AlbumLookupManagerTest.cs b/src/MusicCatalogue.Tests/AlbumLookupManagerTest.cs index ef76e9f..aeedd16 100644 --- a/src/MusicCatalogue.Tests/AlbumLookupManagerTest.cs +++ b/src/MusicCatalogue.Tests/AlbumLookupManagerTest.cs @@ -133,7 +133,7 @@ public void ArtistInDbButAlbumNotInDbTest() public void ArtistAndAlbumInDbTest() { var artistId = Task.Run(() => _factory!.Artists.AddAsync(ArtistName)).Result.Id; - Task.Run(() => _factory!.Albums.AddAsync(artistId, AlbumTitle, Released, Genre, CoverUrl, false)).Wait(); + Task.Run(() => _factory!.Albums.AddAsync(artistId, AlbumTitle, Released, Genre, CoverUrl, false, null, null, null)).Wait(); var album = Task.Run(() => _manager!.LookupAlbum(ArtistName, AlbumTitle, false)).Result; Assert.IsNotNull(album); diff --git a/src/MusicCatalogue.Tests/AlbumManagerTest.cs b/src/MusicCatalogue.Tests/AlbumManagerTest.cs index ed3c0bf..caf424e 100644 --- a/src/MusicCatalogue.Tests/AlbumManagerTest.cs +++ b/src/MusicCatalogue.Tests/AlbumManagerTest.cs @@ -15,8 +15,12 @@ public class AlbumManagerTest private const string TrackTitle = "Blue Train"; private const int TrackNumber = 1; private const int TrackDuration = 643200; + private DateTime Purchased = new(2023, 11, 1); + private const decimal Price = 37.99M; + private const string RetailerName = "Truck Store"; private IMusicCatalogueFactory? _factory; + private int _retailerId; private int _artistId; private int _albumId; @@ -26,16 +30,17 @@ public void TestInitialize() MusicCatalogueDbContext context = MusicCatalogueDbContextFactory.CreateInMemoryDbContext(); _factory = new MusicCatalogueFactory(context); - // Add an artist to the database + // Add the test entities to the database + _retailerId = Task.Run(() => _factory.Retailers.AddAsync(RetailerName)).Result.Id; _artistId = Task.Run(() => _factory.Artists.AddAsync(ArtistName)).Result.Id; - _albumId = Task.Run(() => _factory.Albums.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl, false)).Result.Id; + _albumId = Task.Run(() => _factory.Albums.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl, false, null, null, null)).Result.Id; Task.Run(() => _factory.Tracks.AddAsync(_albumId, TrackTitle, TrackNumber, TrackDuration)).Wait(); } [TestMethod] public async Task AddDuplicateTest() { - await _factory!.Albums.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl, false); + await _factory!.Albums.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl, false, null, null, null); var albums = await _factory!.Albums.ListAsync(x => true); Assert.AreEqual(1, albums.Count); } @@ -52,12 +57,15 @@ public async Task AddAndGetTest() Assert.AreEqual(Genre, album.Genre); Assert.AreEqual(CoverUrl, album.CoverUrl); Assert.IsFalse(album.IsWishListItem); + Assert.IsNull(album.Purchased); + Assert.IsNull(album.Price); + Assert.IsNull(album.RetailerId); } [TestMethod] public async Task UpdateTest() { - var album = await _factory!.Albums.UpdateAsync(_albumId, _artistId, AlbumTitle, Released, Genre, CoverUrl, true); + var album = await _factory!.Albums.UpdateAsync(_albumId, _artistId, AlbumTitle, Released, Genre, CoverUrl, true, Purchased, Price, _retailerId); Assert.IsNotNull(album); Assert.IsTrue(album.Id > 0); Assert.AreEqual(_artistId, album.ArtistId); @@ -66,12 +74,16 @@ public async Task UpdateTest() Assert.AreEqual(Genre, album.Genre); Assert.AreEqual(CoverUrl, album.CoverUrl); Assert.IsTrue(album.IsWishListItem); + Assert.AreEqual(Purchased, album.Purchased); + Assert.AreEqual(Price, album.Price); + Assert.AreEqual(_retailerId, album.RetailerId); + Assert.AreEqual(RetailerName, album.Retailer.Name); } [TestMethod] public async Task UpdateMissingTest() { - var album = await _factory!.Albums.UpdateAsync(-1, _artistId, AlbumTitle, Released, Genre, CoverUrl, true); + var album = await _factory!.Albums.UpdateAsync(-1, _artistId, AlbumTitle, Released, Genre, CoverUrl, true, null, null, null); Assert.IsNull(album); } diff --git a/src/MusicCatalogue.Tests/DataExchangeTest.cs b/src/MusicCatalogue.Tests/DataExchangeTest.cs index feba037..c87474f 100644 --- a/src/MusicCatalogue.Tests/DataExchangeTest.cs +++ b/src/MusicCatalogue.Tests/DataExchangeTest.cs @@ -13,6 +13,9 @@ public class DataExchangeTest private const string Genre = "Jazz"; private const int Released = 1957; private const string CoverUrl = "https://some.server/after-mightnight.jpg"; + private DateTime Purchased = new(2023, 11, 1); + private const decimal Price = 37.99M; + private const string RetailerName = "Truck Store"; private const int TrackNumber = 1; private const string TrackName = "Just You Just Me"; private const int Duration = 180000; @@ -28,8 +31,9 @@ public void Initialise() _factory = new MusicCatalogueFactory(context); // Add an artist, an album and one track + var retailer = Task.Run(() => _factory.Retailers.AddAsync(RetailerName)).Result; var artist = Task.Run(() => _factory.Artists.AddAsync(ArtistName)).Result; - var album = Task.Run(() => _factory.Albums.AddAsync(artist.Id, AlbumName, Released, Genre, CoverUrl, false)).Result; + var album = Task.Run(() => _factory.Albums.AddAsync(artist.Id, AlbumName, Released, Genre, CoverUrl, false, Purchased, Price, retailer.Id)).Result; Task.Run(() => _factory.Tracks.AddAsync(album.Id, TrackName, TrackNumber, Duration)).Wait(); } diff --git a/src/MusicCatalogue.Tests/RetailerManagerTest.cs b/src/MusicCatalogue.Tests/RetailerManagerTest.cs new file mode 100644 index 0000000..f010ea1 --- /dev/null +++ b/src/MusicCatalogue.Tests/RetailerManagerTest.cs @@ -0,0 +1,116 @@ +using MusicCatalogue.Data; +using MusicCatalogue.Entities.Exceptions; +using MusicCatalogue.Entities.Interfaces; +using MusicCatalogue.Logic.Factory; + +namespace MusicCatalogue.Tests +{ + [TestClass] + public class RetailerManagerTest + { + private const string ArtistName = "John Coltrane"; + private const string AlbumTitle = "Blue Train"; + private const int Released = 1957; + private const string Genre = "Jazz"; + private const string CoverUrl = "https://some.host/blue-train.jpg"; + private const string TrackTitle = "Blue Train"; + private const string Name = "Dig Vinyl"; + private const string UpdatedName = "Truck Store"; + + private IMusicCatalogueFactory _factory = null; + private int _retailerId; + + [TestInitialize] + public void TestInitialize() + { + MusicCatalogueDbContext context = MusicCatalogueDbContextFactory.CreateInMemoryDbContext(); + _factory = new MusicCatalogueFactory(context); + _retailerId = Task.Run(() => _factory.Retailers.AddAsync(Name)).Result.Id; + } + + [TestMethod] + public async Task AddDuplicateTest() + { + await _factory!.Retailers.AddAsync(Name); + var retailers = await _factory.Retailers.ListAsync(x => true); + Assert.AreEqual(1, retailers.Count); + } + + [TestMethod] + public async Task AddAndGetTest() + { + var retailer = await _factory!.Retailers.GetAsync(a => a.Name == Name); + Assert.IsNotNull(retailer); + Assert.IsTrue(retailer.Id > 0); + Assert.AreEqual(Name, retailer.Name); + } + + [TestMethod] + public async Task GetMissingTest() + { + var retailer = await _factory!.Retailers.GetAsync(a => a.Name == "Missing"); + Assert.IsNull(retailer); + } + + [TestMethod] + public async Task ListAllTest() + { + var retailers = await _factory!.Retailers.ListAsync(x => true); + Assert.AreEqual(1, retailers!.Count); + Assert.AreEqual(Name, retailers.First().Name); + } + + [TestMethod] + public async Task ListMissingTest() + { + var retailers = await _factory!.Retailers.ListAsync(e => e.Name == "Missing"); + Assert.AreEqual(0, retailers!.Count); + } + + [TestMethod] + public async Task UpdateTest() + { + var retailer = await _factory!.Retailers.UpdateAsync(_retailerId, UpdatedName); + Assert.IsNotNull(retailer); + Assert.IsTrue(retailer.Id > 0); + Assert.AreEqual(UpdatedName, retailer.Name); + } + + [TestMethod] + public async Task UpdateMissingTest() + { + var retailer = await _factory!.Retailers.UpdateAsync(-1, UpdatedName); + Assert.IsNull(retailer); + } + + [TestMethod] + public async Task DeleteTest() + { + await _factory!.Retailers.DeleteAsync(_retailerId); + var retailers = await _factory!.Retailers.ListAsync(x => true); + Assert.IsNotNull(retailers); + Assert.IsFalse(retailers.Any()); + } + + [TestMethod] + public async Task DeleteMissingTest() + { + await _factory!.Retailers.DeleteAsync(-1); + var retailers = await _factory!.Retailers.ListAsync(x => true); + Assert.AreEqual(1, retailers!.Count); + Assert.AreEqual(Name, retailers.First().Name); + } + + [TestMethod] + [ExpectedException(typeof(RetailerInUseException))] + public async Task DeleteInUseTest() + { + // Add an album that uses the retailer + var artist = await _factory.Artists.AddAsync(ArtistName); + await _factory.Albums.AddAsync(artist.Id, AlbumTitle, Released, Genre, CoverUrl, false, null, null, _retailerId); + + // Now try to delete the retailer - this should raise an exception + await _factory!.Retailers.DeleteAsync(_retailerId); + } + } +} diff --git a/src/MusicCatalogue.Tests/StatisticsManagerTest.cs b/src/MusicCatalogue.Tests/StatisticsManagerTest.cs index d89eb95..bf768ef 100644 --- a/src/MusicCatalogue.Tests/StatisticsManagerTest.cs +++ b/src/MusicCatalogue.Tests/StatisticsManagerTest.cs @@ -12,11 +12,16 @@ public class StatisticsManagerTest private const int Released = 1957; private const string Genre = "Jazz"; private const string CoverUrl = "https://some.host/blue-train.jpg"; + private DateTime Purchased = new(2023, 11, 1); + private const decimal Price = 37.99M; + private const string RetailerName = "Truck Store"; private const string TrackTitle = "Blue Train"; private const int TrackNumber = 1; private const int TrackDuration = 643200; private IMusicCatalogueFactory? _factory = null; + private int _retailerId; + private int _artistId; [TestInitialize] public void TestInitialize() @@ -25,23 +30,48 @@ public void TestInitialize() _factory = new MusicCatalogueFactory(context); // Set up an artist and album for the tracks to belong to and add a track - var artistId = Task.Run(() => _factory.Artists.AddAsync(ArtistName)).Result.Id; - var albumId = Task.Run(() => _factory.Albums.AddAsync(artistId, AlbumTitle, Released, Genre, CoverUrl, false)).Result.Id; - Task.Run(() => _factory.Tracks.AddAsync(albumId, TrackTitle, TrackNumber, TrackDuration)).Wait(); + _retailerId = Task.Run(() => _factory.Retailers.AddAsync(RetailerName)).Result.Id; + _artistId = Task.Run(() => _factory.Artists.AddAsync(ArtistName)).Result.Id; } [TestMethod] - public void ArtistStatisticsTest() + public void ArtistStatisticsForMainCatalogueTest() { + // Add an album to the main catalogue + var albumId = Task.Run(() => _factory!.Albums.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl, false, Purchased, Price, _retailerId)).Result.Id; + Task.Run(() => _factory!.Tracks.AddAsync(albumId, TrackTitle, TrackNumber, TrackDuration)).Wait(); + var artists = Task.Run(() => _factory!.Artists.ListAsync(x => true)).Result; Assert.IsNotNull(artists); Assert.AreEqual(1, artists.Count); Assert.IsNull(artists[0].AlbumCount); Assert.IsNull(artists[0].TrackCount); + Assert.AreEqual(0M, artists[0].TotalAlbumSpend); Task.Run(() => _factory!.Statistics.PopulateArtistStatistics(artists, false)).Wait(); Assert.AreEqual(1, artists[0].AlbumCount); Assert.AreEqual(1, artists[0].TrackCount); + Assert.AreEqual(Price, artists[0].TotalAlbumSpend); + } + + [TestMethod] + public void ArtistStatisticsForWishListTest() + { + // Add an album to the wish list + var albumId = Task.Run(() => _factory!.Albums.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl, true, Purchased, Price, _retailerId)).Result.Id; + Task.Run(() => _factory!.Tracks.AddAsync(albumId, TrackTitle, TrackNumber, TrackDuration)).Wait(); + + var artists = Task.Run(() => _factory!.Artists.ListAsync(x => true)).Result; + Assert.IsNotNull(artists); + Assert.AreEqual(1, artists.Count); + Assert.IsNull(artists[0].AlbumCount); + Assert.IsNull(artists[0].TrackCount); + Assert.AreEqual(0M, artists[0].TotalAlbumSpend); + + Task.Run(() => _factory!.Statistics.PopulateArtistStatistics(artists, true)).Wait(); + Assert.AreEqual(1, artists[0].AlbumCount); + Assert.AreEqual(1, artists[0].TrackCount); + Assert.AreEqual(Price, artists[0].TotalAlbumSpend); } } } diff --git a/src/MusicCatalogue.Tests/TrackManagerTest.cs b/src/MusicCatalogue.Tests/TrackManagerTest.cs index 3a8119e..468fac0 100644 --- a/src/MusicCatalogue.Tests/TrackManagerTest.cs +++ b/src/MusicCatalogue.Tests/TrackManagerTest.cs @@ -28,7 +28,7 @@ public void TestInitialize() // Set up an artist and album for the tracks to belong to _artistId = Task.Run(() => factory.Artists.AddAsync(ArtistName)).Result.Id; - _albumId = Task.Run(() => factory.Albums.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl, false)).Result.Id; + _albumId = Task.Run(() => factory.Albums.AddAsync(_artistId, AlbumTitle, Released, Genre, CoverUrl, false, null, null, null)).Result.Id; // Create a track manager and add a test track _manager = factory.Tracks; diff --git a/src/music-catalogue-ui/components/albumList.js b/src/music-catalogue-ui/components/albumList.js index 1cd07c5..ce78748 100644 --- a/src/music-catalogue-ui/components/albumList.js +++ b/src/music-catalogue-ui/components/albumList.js @@ -34,6 +34,9 @@ const AlbumList = ({ artist, isWishList, navigate, logout }) => { Album Title Genre Released + Purchased + Price + Retailer diff --git a/src/music-catalogue-ui/components/albumPurchaseDetails.js b/src/music-catalogue-ui/components/albumPurchaseDetails.js new file mode 100644 index 0000000..c79ce59 --- /dev/null +++ b/src/music-catalogue-ui/components/albumPurchaseDetails.js @@ -0,0 +1,167 @@ +import styles from "./albumPurchaseDetails.module.css"; +import DatePicker from "react-datepicker"; +import { useState, useCallback } from "react"; +import CurrencyInput from "react-currency-input-field"; +import config from "../config.json"; +import pages from "../helpers/navigation"; +import { apiCreateRetailer } from "@/helpers/apiRetailers"; +import { apiSetAlbumPurchaseDetails } from "@/helpers/apiAlbums"; + +/** + * Form to set the album purchase details for an album + * @param {*} artist + * @param {*} album + * @param {*} isWishList + * @param {*} navigate + * @param {*} logout + */ +const AlbumPurchaseDetails = ({ artist, album, navigate, logout }) => { + // Get the retailer name and purchase date from the album + const initialRetailerName = + album["retailer"] != null ? album["retailer"]["name"] : ""; + const initialPurchaseDate = + album.purchased != null ? new Date(album.purchased) : new Date(); + + // Set up state + const [purchaseDate, setPurchaseDate] = useState(initialPurchaseDate); + const [price, setPrice] = useState(album.price); + const [retailerName, setRetailerName] = useState(initialRetailerName); + const [errorMessage, setErrorMessage] = useState(""); + + /* Callback to set album purchase details */ + const setAlbumPurchaseDetails = useCallback( + async (e) => { + // Prevent the default action associated with the click event + e.preventDefault(); + + // See if we have a retailer name. If so, create/retrieve the retailer and + // capture the retailer ID + var retailerId = null; + if (retailerName != "") { + const retailer = await apiCreateRetailer(retailerName, logout); + if (retailer != null) { + retailerId = retailer.id; + } else { + setErrorMessage(`Error creating retailer "${retailerName}"`); + return; + } + } + + // Construct the remaining values to be passed to the API + const updatedPurchaseDate = + album.isWishListItem == true ? null : purchaseDate; + const updatedPrice = price == undefined ? null : price; + + // Apply the updates + const updatedAlbum = await apiSetAlbumPurchaseDetails( + album, + updatedPurchaseDate, + updatedPrice, + retailerId, + logout + ); + + // If the returned album is valid, navigate back to the albums by artist page. + // Otherwise, show an error + if (updatedAlbum != null) { + navigate( + pages.albums, + artist, + updatedAlbum, + updatedAlbum.isWishListItem + ); + } else { + setErrorMessage("Error updating the album purchase details"); + } + }, + [artist, album, purchaseDate, price, retailerName, logout, navigate] + ); + + return ( + <> +
+
+ {album.title} - {artist.name} - Purchase Details +
+
+
+
+
+ {album.isWishListItem == true ? ( + <> + ) : ( +
+ +
+ setPurchaseDate(date)} + /> +
+
+ )} +
+ +
+ setPrice(value)} + /> +
+
+
+ + setRetailerName(e.target.value)} + /> +
+
+ + {errorMessage} + +
+
+
+
+ +
+
+ +
+
+
+
+ + ); +}; + +export default AlbumPurchaseDetails; diff --git a/src/music-catalogue-ui/components/albumPurchaseDetails.module.css b/src/music-catalogue-ui/components/albumPurchaseDetails.module.css new file mode 100644 index 0000000..1fedcba --- /dev/null +++ b/src/music-catalogue-ui/components/albumPurchaseDetails.module.css @@ -0,0 +1,27 @@ +.purchaseDetailsFormContainer { + display: flex; + justify-content: center; + align-items: center; +} + +.purchaseDetailsForm { + width: 420px; + padding-top: 20px; + padding-bottom: 20px; +} + +.purchaseDetailsFormLabel { + font-size: 14px; + font-weight: 600; + color: rgb(34, 34, 34); +} + +.purchaseDetailsButton { + margin-left: 10px; + float: right; +} + +.purchaseDetailsError { + font-weight: bold; + color: red; +} diff --git a/src/music-catalogue-ui/components/albumRow.js b/src/music-catalogue-ui/components/albumRow.js index b226d0f..a67be77 100644 --- a/src/music-catalogue-ui/components/albumRow.js +++ b/src/music-catalogue-ui/components/albumRow.js @@ -1,6 +1,10 @@ import pages from "@/helpers/navigation"; import DeleteAlbumActionIcon from "./deleteAlbumActionIcon"; import AlbumWishListActionIcon from "./albumWishListActionIcon"; +import CurrencyFormatter from "./currencyFormatter"; +import DateFormatter from "./dateFormatter"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCoins } from "@fortawesome/free-solid-svg-icons"; /** * Component to render a row containing the details of a single album @@ -20,6 +24,10 @@ const AlbumRow = ({ logout, setAlbums, }) => { + // Get the retailer name + const retailer = album["retailer"]; + const retailerName = retailer != null ? retailer["name"] : ""; + return ( navigate(pages.tracks, artist, album, isWishList)}> @@ -34,6 +42,15 @@ const AlbumRow = ({ navigate(pages.tracks, artist, album, isWishList)}> {album.released} + navigate(pages.tracks, artist, album, isWishList)}> + + + navigate(pages.tracks, artist, album, isWishList)}> + + + navigate(pages.tracks, artist, album, isWishList)}> + {retailerName} + + + + navigate(pages.albumPurchaseDetails, artist, album, false) + } + /> + ); }; diff --git a/src/music-catalogue-ui/components/albumWishListActionIcon.js b/src/music-catalogue-ui/components/albumWishListActionIcon.js index 2195098..a95ea9a 100644 --- a/src/music-catalogue-ui/components/albumWishListActionIcon.js +++ b/src/music-catalogue-ui/components/albumWishListActionIcon.js @@ -4,8 +4,20 @@ import { faHeartCirclePlus, faRecordVinyl, } from "@fortawesome/free-solid-svg-icons"; -import { apiSetAlbumWishListFlag, apiFetchAlbumsByArtist } from "@/helpers/api"; +import { + apiSetAlbumWishListFlag, + apiFetchAlbumsByArtist, +} from "@/helpers/apiAlbums"; +/** + * Icon and associated action to move an album between the catalogue and wish list + * @param {*} artistId + * @param {*} album + * @param {*} isWishList + * @param {*} logout + * @param {*} setAlbums + * @returns + */ const AlbumWishListActionIcon = ({ artistId, album, diff --git a/src/music-catalogue-ui/components/app.js b/src/music-catalogue-ui/components/app.js index a508ad4..49ceef2 100644 --- a/src/music-catalogue-ui/components/app.js +++ b/src/music-catalogue-ui/components/app.js @@ -2,7 +2,7 @@ import React, { useCallback, useState } from "react"; import Login from "./login"; import pages from "@/helpers/navigation"; import ComponentPicker from "./componentPicker"; -import { apiClearToken } from "@/helpers/api"; +import { apiClearToken } from "@/helpers/apiToken"; import useIsLoggedIn from "@/hooks/useIsLoggedIn"; import MenuBar from "./menuBar"; diff --git a/src/music-catalogue-ui/components/artistList.js b/src/music-catalogue-ui/components/artistList.js index 8813ce5..5f1d12a 100644 --- a/src/music-catalogue-ui/components/artistList.js +++ b/src/music-catalogue-ui/components/artistList.js @@ -1,7 +1,5 @@ -import { useCallback } from "react"; import useArtists from "@/hooks/useArtists"; import ArtistRow from "./artistRow"; -import pages from "@/helpers/navigation"; /** * Component to render a table listing all the artists in the catalogue @@ -27,6 +25,7 @@ const ArtistList = ({ isWishList, navigate, logout }) => { Name Albums Tracks + Total Spend {artists != null && ( diff --git a/src/music-catalogue-ui/components/artistRow.js b/src/music-catalogue-ui/components/artistRow.js index 43b6b93..14b3532 100644 --- a/src/music-catalogue-ui/components/artistRow.js +++ b/src/music-catalogue-ui/components/artistRow.js @@ -1,4 +1,5 @@ import pages from "@/helpers/navigation"; +import CurrencyFormatter from "./currencyFormatter"; /** * Component to render a row containing the details for a single artist @@ -13,6 +14,12 @@ const ArtistRow = ({ artist, isWishList, navigate }) => { {artist.name} {artist.albumCount} {artist.trackCount} + + + ); }; diff --git a/src/music-catalogue-ui/components/componentPicker.js b/src/music-catalogue-ui/components/componentPicker.js index c09a597..6d56aa7 100644 --- a/src/music-catalogue-ui/components/componentPicker.js +++ b/src/music-catalogue-ui/components/componentPicker.js @@ -5,6 +5,7 @@ import TrackList from "./trackList"; import LookupAlbum from "./lookupAlbum"; import ExportCatalogue from "./exportCatalogue"; import JobStatusReport from "./jobStatusReport"; +import AlbumPurchaseDetails from "./albumPurchaseDetails"; /** * Component using the current page name to render the components required @@ -49,6 +50,15 @@ const ComponentPicker = ({ context, navigate, logout }) => { return ; case pages.jobStatusReport: return ; + case pages.albumPurchaseDetails: + return ( + + ); default: return ; } diff --git a/src/music-catalogue-ui/components/currencyFormatter.js b/src/music-catalogue-ui/components/currencyFormatter.js new file mode 100644 index 0000000..1b39915 --- /dev/null +++ b/src/music-catalogue-ui/components/currencyFormatter.js @@ -0,0 +1,26 @@ +import config from "../config.json"; + +/** + * Format a value as currency using the locale from the config file + * @param {*} param0 + * @returns + */ +const CurrencyFormatter = ({ value, renderZeroAsBlank }) => { + // Check there's a value to format + if (value != null && (value > 0 || !renderZeroAsBlank)) { + // Create a formatter to format the value + const formatter = new Intl.NumberFormat(config.region.locale, { + style: "currency", + currency: config.region.currency, + }); + + // Format the value + const formattedValue = formatter.format(value); + + return <>{formattedValue}; + } else { + return <>; + } +}; + +export default CurrencyFormatter; diff --git a/src/music-catalogue-ui/components/dateFormatter.js b/src/music-catalogue-ui/components/dateFormatter.js new file mode 100644 index 0000000..9e76394 --- /dev/null +++ b/src/music-catalogue-ui/components/dateFormatter.js @@ -0,0 +1,24 @@ +import config from "../config.json"; + +/** + * Format a value as a date using the locale from the config file + * @param {*} param0 + * @returns + */ +const DateFormatter = ({ value }) => { + // Check there's a value to format + if (value != null) { + // Create a formatter to format the value + const formatter = new Intl.DateTimeFormat(config.region.locale); + + // Format the value + const dateToFormat = new Date(value); + const formattedValue = formatter.format(dateToFormat); + + return <>{formattedValue}; + } else { + return <>; + } +}; + +export default DateFormatter; diff --git a/src/music-catalogue-ui/components/deleteAlbumActionIcon.js b/src/music-catalogue-ui/components/deleteAlbumActionIcon.js index 092d285..dfa2284 100644 --- a/src/music-catalogue-ui/components/deleteAlbumActionIcon.js +++ b/src/music-catalogue-ui/components/deleteAlbumActionIcon.js @@ -1,8 +1,17 @@ import { useCallback } from "react"; -import { apiDeleteAlbum, apiFetchAlbumsByArtist } from "@/helpers/api"; +import { apiDeleteAlbum, apiFetchAlbumsByArtist } from "@/helpers/apiAlbums"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; +/** + * Icon and associated action to delete an album + * @param {*} artistId + * @param {*} album + * @param {*} isWishList + * @param {*} logout + * @param {*} setAlbums + * @returns + */ const DeleteAlbumActionIcon = ({ artistId, album, diff --git a/src/music-catalogue-ui/components/exportCatalogue.js b/src/music-catalogue-ui/components/exportCatalogue.js index 109b4f1..53ec398 100644 --- a/src/music-catalogue-ui/components/exportCatalogue.js +++ b/src/music-catalogue-ui/components/exportCatalogue.js @@ -1,7 +1,12 @@ import styles from "./exportCatalogue.module.css"; import { useCallback, useState } from "react"; -import { apiRequestExport } from "@/helpers/api"; +import { apiRequestExport } from "@/helpers/apiDataExchange"; +/** + * Component to prompt for an export file name and request an export of the catalogue + * @param {*} logout + * @returns + */ const ExportCatalogue = ({ logout }) => { const [fileName, setFileName] = useState(""); const [message, setMessage] = useState(""); diff --git a/src/music-catalogue-ui/components/jobStatusReport.js b/src/music-catalogue-ui/components/jobStatusReport.js index 79bfc09..79024a8 100644 --- a/src/music-catalogue-ui/components/jobStatusReport.js +++ b/src/music-catalogue-ui/components/jobStatusReport.js @@ -2,9 +2,14 @@ import React, { useCallback, useState } from "react"; import styles from "./jobStatusReport.module.css"; import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; -import { apiJobStatusReport } from "@/helpers/api"; +import { apiJobStatusReport } from "@/helpers/apiReports"; import JobStatusRow from "./jobStatusRow"; +/** + * Component to display the job status search page and its results + * @param {*} logout + * @returns + */ const JobStatusReport = ({ logout }) => { const [startDate, setStartDate] = useState(new Date()); const [endDate, setEndDate] = useState(new Date()); diff --git a/src/music-catalogue-ui/components/login.js b/src/music-catalogue-ui/components/login.js index c9f72e3..ee8efae 100644 --- a/src/music-catalogue-ui/components/login.js +++ b/src/music-catalogue-ui/components/login.js @@ -1,6 +1,7 @@ import styles from "./login.module.css"; import { React, useState, useCallback } from "react"; -import { apiAuthenticate, apiSetToken } from "@/helpers/api"; +import { apiAuthenticate } from "@/helpers/apiAuthenticate"; +import { apiSetToken } from "@/helpers/apiToken"; /** * Component to render the login page diff --git a/src/music-catalogue-ui/components/lookupAlbum.js b/src/music-catalogue-ui/components/lookupAlbum.js index 1216e17..ecd531e 100644 --- a/src/music-catalogue-ui/components/lookupAlbum.js +++ b/src/music-catalogue-ui/components/lookupAlbum.js @@ -1,7 +1,8 @@ import styles from "./lookupAlbum.module.css"; import pages from "@/helpers/navigation"; import { useCallback, useState } from "react"; -import { apiFetchArtistById, apiLookupAlbum } from "@/helpers/api"; +import { apiFetchArtistById } from "@/helpers/apiArtists"; +import { apiLookupAlbum } from "@/helpers/apiAlbums"; import Select from "react-select"; /** @@ -18,32 +19,38 @@ const LookupAlbum = ({ navigate, logout }) => { const [target, setTarget] = useState("wishlist"); // Lookup navigation callback - const lookup = useCallback(async () => { - // Determine where to store new albums based on the target drop-down - const storeInWishList = target.value == "wishlist"; + const lookup = useCallback( + async (e) => { + // Prevent the default action associated with the click event + e.preventDefault(); - // Lookup the album - this will preferentially use the local database via the - // REST API and fallback to the external API if needed - const album = await apiLookupAlbum( - artistName, - albumTitle, - storeInWishList, - logout - ); - if (album != null) { - // The album only contains the artist ID, not the full artist details, but - // they will now be stored locally, so fetch them - const artist = await apiFetchArtistById(album.artistId, logout); - if (artist != null) { - // Navigate to the track list - navigate(pages.tracks, artist, album, storeInWishList); + // Determine where to store new albums based on the target drop-down + const storeInWishList = target.value == "wishlist"; + + // Lookup the album - this will preferentially use the local database via the + // REST API and fallback to the external API if needed + const album = await apiLookupAlbum( + artistName, + albumTitle, + storeInWishList, + logout + ); + if (album != null) { + // The album only contains the artist ID, not the full artist details, but + // they will now be stored locally, so fetch them + const artist = await apiFetchArtistById(album.artistId, logout); + if (artist != null) { + // Navigate to the track list + navigate(pages.tracks, artist, album, storeInWishList); + } else { + setErrorMessage(`Artist with id ${album.artistId} not found`); + } } else { - setErrorMessage(`Artist with id ${album.artistId} not found`); + setErrorMessage(`Album "${albumTitle}" by "${artistName}" not found`); } - } else { - setErrorMessage(`Album "${albumTitle}" by "${artistName}" not found`); - } - }, [artistName, albumTitle, target, navigate, logout]); + }, + [artistName, albumTitle, target, navigate, logout] + ); // Construct a list of select list options for the target directory const options = [ @@ -57,7 +64,7 @@ const LookupAlbum = ({ navigate, logout }) => {
Lookup Album
-
+
@@ -97,7 +104,7 @@ const LookupAlbum = ({ navigate, logout }) => {
-
+
); diff --git a/src/music-catalogue-ui/components/menuBar.js b/src/music-catalogue-ui/components/menuBar.js index 125f5e3..a76343a 100644 --- a/src/music-catalogue-ui/components/menuBar.js +++ b/src/music-catalogue-ui/components/menuBar.js @@ -3,6 +3,12 @@ import pages from "../helpers/navigation"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCaretDown } from "@fortawesome/free-solid-svg-icons"; +/** + * Component to render the menu bar + * @param {*} navigate + * @param {*} logout + * @returns + */ const MenuBar = ({ navigate, logout }) => { return ( <> diff --git a/src/music-catalogue-ui/config.json b/src/music-catalogue-ui/config.json index 4f8931f..f6ff124 100644 --- a/src/music-catalogue-ui/config.json +++ b/src/music-catalogue-ui/config.json @@ -1,5 +1,9 @@ { "api": { "baseUrl": "http://localhost:8098" + }, + "region": { + "locale": "en-GB", + "currency": "GBP" } } diff --git a/src/music-catalogue-ui/helpers/api.js b/src/music-catalogue-ui/helpers/api.js deleted file mode 100644 index c43ab43..0000000 --- a/src/music-catalogue-ui/helpers/api.js +++ /dev/null @@ -1,437 +0,0 @@ -import config from "../config.json"; - -/** - * Store the JWT token - * @param {*} token - */ -const apiSetToken = (token) => { - // TODO: Move to HTTP Cookie - localStorage.setItem("token", token); -}; - -/** - * Retrieve the current JWT token - * @param {*} token - * @returns - */ -const apiGetToken = () => { - try { - // TODO: Move to HTTP Cookie - const token = localStorage.getItem("token"); - return token; - } catch { - return null; - } -}; - -/** - * Clear the current JWT token - */ -const apiClearToken = () => { - // TODO: Move to HTTP Cookie - localStorage.removeItem("token"); -}; - -/** - * Return the HTTP headers used when sending GET requests to the REST API - * @returns - */ -const apiGetHeaders = () => { - // Get the token - var token = apiGetToken(); - - // Construct the request headers - const headers = { - Authorization: `Bearer ${token}`, - }; - - return headers; -}; - -/** - * Return the HTTP headers used when sending POST and PUT requests to the REST API - * @returns - */ -const apiGetPostHeaders = () => { - // Get the token - var token = apiGetToken(); - - // Construct the request headers - const headers = { - Accept: "application/json", - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }; - - return headers; -}; - -/** - * Format a date/time in a manner suitable for use with the API without using - * 3rd party libraries - * @param {F} date - */ -const apiFormatDateTime = (date) => { - // Get the formatted date components - const year = date.getFullYear(); - const month = (date.getMonth() + 1).toString().padStart(2, "0"); - const day = date.getDate().toString().padStart(2, "0"); - - // Get the formatted time components - const hours = date.getHours().toString().padStart(2, "0"); - const minutes = date.getMinutes().toString().padStart(2, "0"); - const seconds = date.getSeconds().toString().padStart(2, "0"); - - // Construct and return the formatted date/time - const formattedDate = - year + - "-" + - month + - "-" + - day + - "%20" + - hours + - ":" + - minutes + - ":" + - seconds; - return formattedDate; -}; - -/** - * Read the response content as JSON and return the resulting object - * @param {*} response - * @returns - */ -const apiReadResponseData = async (response) => { - // The API can return a No Content response so check for that first - if (response.status == 204) { - // No content response, so return null - return null; - } else { - // Get the response content as JSON and return it - const data = await response.json(); - return data; - } -}; - -/** - * Authenticate with the Music Catalogue REST API - * @param {*} username - * @param {*} password - * @returns - */ -const apiAuthenticate = async (username, password) => { - // Create a JSON body containing the credentials - const body = JSON.stringify({ - userName: username, - password: password, - }); - - // Call the API to authenticate as the specified user and return a token - const url = `${config.api.baseUrl}/users/authenticate/`; - const response = await fetch(url, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: body, - }); - - // Get the response text - const token = await response.text(); - return token.replace(/"/g, ""); -}; - -/** - * Fetch a list of all artists from the Music Catalogue REST API - * @param {*} isWishList - * @param {*} logout - * @returns - */ -const apiFetchAllArtists = async (isWishList, logout) => { - // Call the API to get a list of all artists - const url = `${config.api.baseUrl}/artists/${isWishList}`; - const response = await fetch(url, { - method: "GET", - headers: apiGetHeaders(), - }); - - if (response.ok) { - // Get the response content as JSON and return it - const artists = await apiReadResponseData(response); - return artists; - } else if (response.status == 401) { - // Unauthorized so the token's likely expired - force a login - logout(); - } else { - return null; - } -}; - -/** - * Fetch the details for a single artist from the Music Catalogue REST API - * given the artist ID - * @param {*} artistId - * @param {*} logout - * @returns - */ -const apiFetchArtistById = async (artistId, logout) => { - // Call the API to get the artist details - const url = `${config.api.baseUrl}/artists/${artistId}`; - const response = await fetch(url, { - method: "GET", - headers: apiGetHeaders(), - }); - - if (response.ok) { - // Get the response content as JSON and return it - const artist = await apiReadResponseData(response); - return artist; - } else if (response.status == 401) { - // Unauthorized so the token's likely expired - force a login - logout(); - } else { - return null; - } -}; - -/** - * Fetch a list of albums by the specified artist from the Music Catalogue - * REST API - * @param {*} artistId - * @param {*} isWishList - * @param {*} logout - * @returns - */ -const apiFetchAlbumsByArtist = async (artistId, isWishList, logout) => { - // Call the API to get a list of all albums by the specified artist - const url = `${config.api.baseUrl}/albums/artist/${artistId}/${isWishList}`; - console.log(url); - const response = await fetch(url, { - method: "GET", - headers: apiGetHeaders(), - }); - - if (response.ok) { - // Get the response content as JSON and return it - const albums = await apiReadResponseData(response); - return albums; - } else if (response.status == 401) { - // Unauthorized so the token's likely expired - force a login - logout(); - } else { - return null; - } -}; - -/** - * Return the album details and track list for the specified album from the - * Music Catalogue REST API - * @param {*} albumId - * @param {*} logout - * @returns - */ -const apiFetchAlbumById = async (albumId, logout) => { - // Call the API to get the details for the specified album - const url = `${config.api.baseUrl}/albums/${albumId}`; - const response = await fetch(url, { - method: "GET", - headers: apiGetHeaders(), - }); - - if (response.ok) { - // Get the response content as JSON and return it - const album = await apiReadResponseData(response); - return album; - } else if (response.status == 401) { - // Unauthorized so the token's likely expired - force a login - logout(); - } else { - return null; - } -}; - -/** - * Delete the album with the specified ID, along with all its tracks - * @param {*} albumId - * @param {*} logout - * @returns - */ -const apiDeleteAlbum = async (albumId, logout) => { - // Call the API to delete the specified album - const url = `${config.api.baseUrl}/albums/${albumId}`; - const response = await fetch(url, { - method: "DELETE", - headers: apiGetHeaders(), - }); - - if (response.status == 401) { - // Unauthorized so the token's likely expired - force a login - logout(); - } else { - // Return the response status code - return response.ok; - } -}; - -/** - * Look up an album using the REST API, calling the external service for the - * details if not found in the local database - * @param {*} artistName - * @param {*} albumTitle - * @param {*} storeInWishList - * @param {*} logout - */ -const apiLookupAlbum = async ( - artistName, - albumTitle, - storeInWishList, - logout -) => { - // URL encode the lookup properties - const encodedArtistName = encodeURIComponent(artistName); - const encodedAlbumTitle = encodeURIComponent(albumTitle); - - // Call the API to get the details for the specified album - const url = `${config.api.baseUrl}/search/${encodedArtistName}/${encodedAlbumTitle}/${storeInWishList}`; - const response = await fetch(url, { - method: "GET", - headers: apiGetHeaders(), - }); - - if (response.ok) { - // Get the response content as JSON and return it - const album = await apiReadResponseData(response); - return album; - } else if (response.status == 401) { - // Unauthorized so the token's likely expired - force a login - logout(); - } else { - return null; - } -}; - -/** - * Set the wish list flag on an album - * @param {*} album - * @param {*} wishListFlag - * @param {*} logout - * @returns - */ -const apiSetAlbumWishListFlag = async (album, wishListFlag, logout) => { - // Construct the body - the wish list flat needs to be updated before this - // and there's no need to send the track information - an empty array will do - album.isWishListItem = wishListFlag; - album.tracks = []; - const body = JSON.stringify(album); - console.log(body); - - // Call the API to set the wish list flag for a given album - const url = `${config.api.baseUrl}/albums`; - const response = await fetch(url, { - method: "PUT", - headers: apiGetPostHeaders(), - body: body, - }); - - if (response.ok) { - // Get the response content as JSON and return it - const album = await apiReadResponseData(response); - return album; - } else if (response.status == 401) { - // Unauthorized so the token's likely expired - force a login - logout(); - } else { - return null; - } -}; - -/** - * Request an export of the catalogue - * @param {*} fileName - * @param {*} logout - */ -const apiRequestExport = async (fileName, logout) => { - // Create a JSON body containing the file name to export to - const body = JSON.stringify({ - fileName: fileName, - }); - - // Call the API to request the export - const url = `${config.api.baseUrl}/export/catalogue`; - const response = await fetch(url, { - method: "POST", - headers: apiGetPostHeaders(), - body: body, - }); - - if (response.status == 401) { - // Unauthorized so the token's likely expired - force a login - logout(); - } - - return response.ok; -}; - -const apiJobStatusReport = async (from, to, logout) => { - // Make sure the dates cover the period 00:00:00 on the start date to 23:59:59 on the - // end date - const startDate = new Date( - from.getFullYear(), - from.getMonth(), - from.getDate(), - 0, - 0, - 0 - ); - - const endDate = new Date( - to.getFullYear(), - to.getMonth(), - to.getDate(), - 23, - 59, - 59 - ); - - // Construct the route. Dates need to be formatted in a specific fashion for the API - // to decode them and they also need to be URL encoded - const fromRouteSegment = apiFormatDateTime(startDate); - const toRouteSegment = apiFormatDateTime(endDate); - const url = `${config.api.baseUrl}/reports/jobs/${fromRouteSegment}/${toRouteSegment}`; - console.log(url); - // Call the API to get content for the report - const response = await fetch(url, { - method: "GET", - headers: apiGetHeaders(), - }); - - if (response.ok) { - // Get the response content as JSON and return it - const records = await apiReadResponseData(response); - return records; - } else if (response.status == 401) { - // Unauthorized so the token's likely expired - force a login - logout(); - } else { - return null; - } -}; - -export { - apiSetToken, - apiGetToken, - apiClearToken, - apiAuthenticate, - apiFetchAllArtists, - apiFetchArtistById, - apiFetchAlbumsByArtist, - apiFetchAlbumById, - apiDeleteAlbum, - apiLookupAlbum, - apiSetAlbumWishListFlag, - apiRequestExport, - apiJobStatusReport, -}; diff --git a/src/music-catalogue-ui/helpers/apiAlbums.js b/src/music-catalogue-ui/helpers/apiAlbums.js new file mode 100644 index 0000000..0cbc433 --- /dev/null +++ b/src/music-catalogue-ui/helpers/apiAlbums.js @@ -0,0 +1,167 @@ +import config from "../config.json"; +import { apiReadResponseData } from "./apiUtils"; +import { apiGetPostHeaders, apiGetHeaders } from "./apiHeaders"; + +/** + * POST a request to the API to update the specified album's details + * @param {*} album + * @param {*} logout + * @returns + */ +const apiAlbumUpdate = async (album, logout) => { + // Construct the body - the wish list flat needs to be updated before this + const body = JSON.stringify(album); + + // Call the API to set the wish list flag for a given album + const url = `${config.api.baseUrl}/albums`; + const response = await fetch(url, { + method: "PUT", + headers: apiGetPostHeaders(), + body: body, + }); + + const updatedAlbum = await apiReadResponseData(response, logout); + return updatedAlbum; +}; + +/** + * Fetch a list of albums by the specified artist from the Music Catalogue + * REST API + * @param {*} artistId + * @param {*} isWishList + * @param {*} logout + * @returns + */ +const apiFetchAlbumsByArtist = async (artistId, isWishList, logout) => { + // Call the API to get a list of all albums by the specified artist + const url = `${config.api.baseUrl}/albums/artist/${artistId}/${isWishList}`; + const response = await fetch(url, { + method: "GET", + headers: apiGetHeaders(), + }); + + const albums = await apiReadResponseData(response, logout); + return albums; +}; + +/** + * Return the album details and track list for the specified album from the + * Music Catalogue REST API + * @param {*} albumId + * @param {*} logout + * @returns + */ +const apiFetchAlbumById = async (albumId, logout) => { + // Call the API to get the details for the specified album + const url = `${config.api.baseUrl}/albums/${albumId}`; + const response = await fetch(url, { + method: "GET", + headers: apiGetHeaders(), + }); + + const album = await apiReadResponseData(response, logout); + return album; +}; + +/** + * Delete the album with the specified ID, along with all its tracks + * @param {*} albumId + * @param {*} logout + * @returns + */ +const apiDeleteAlbum = async (albumId, logout) => { + // Call the API to delete the specified album + const url = `${config.api.baseUrl}/albums/${albumId}`; + const response = await fetch(url, { + method: "DELETE", + headers: apiGetHeaders(), + }); + + if (response.status == 401) { + // Unauthorized so the token's likely expired - force a login + logout(); + } else { + // Return the response status code + return response.ok; + } +}; + +/** + * Look up an album using the REST API, calling the external service for the + * details if not found in the local database + * @param {*} artistName + * @param {*} albumTitle + * @param {*} storeInWishList + * @param {*} logout + */ +const apiLookupAlbum = async ( + artistName, + albumTitle, + storeInWishList, + logout +) => { + // URL encode the lookup properties + const encodedArtistName = encodeURIComponent(artistName); + const encodedAlbumTitle = encodeURIComponent(albumTitle); + + // Call the API to get the details for the specified album + const url = `${config.api.baseUrl}/search/${encodedArtistName}/${encodedAlbumTitle}/${storeInWishList}`; + const response = await fetch(url, { + method: "GET", + headers: apiGetHeaders(), + }); + + const album = await apiReadResponseData(response, logout); + return album; +}; + +/** + * Set the wish list flag on an album + * @param {*} album + * @param {*} wishListFlag + * @param {*} logout + * @returns + */ +const apiSetAlbumWishListFlag = async (album, wishListFlag, logout) => { + // Update the album properties + album.isWishListItem = wishListFlag; + + // Send the update request to the API and return the response + const response = await apiAlbumUpdate(album, logout); + return response; +}; + +/** + * Set the purchase details for the specified album + * @param {*} album + * @param {*} purchaseDate + * @param {*} price + * @param {*} retailerId + * @param {*} logout + * @returns + */ +const apiSetAlbumPurchaseDetails = async ( + album, + purchaseDate, + price, + retailerId, + logout +) => { + // Update the purchase details + album.purchased = purchaseDate; + album.price = price; + album.retailerId = retailerId; + + // Send the update request to the API and return the response + const response = await apiAlbumUpdate(album, logout); + return response; +}; + +export { + apiFetchAlbumsByArtist, + apiFetchAlbumById, + apiDeleteAlbum, + apiLookupAlbum, + apiSetAlbumWishListFlag, + apiSetAlbumPurchaseDetails, +}; diff --git a/src/music-catalogue-ui/helpers/apiArtists.js b/src/music-catalogue-ui/helpers/apiArtists.js new file mode 100644 index 0000000..4a03773 --- /dev/null +++ b/src/music-catalogue-ui/helpers/apiArtists.js @@ -0,0 +1,42 @@ +import config from "../config.json"; +import { apiReadResponseData } from "./apiUtils"; +import { apiGetHeaders } from "./apiHeaders"; + +/** + * Fetch a list of all artists from the Music Catalogue REST API + * @param {*} isWishList + * @param {*} logout + * @returns + */ +const apiFetchAllArtists = async (isWishList, logout) => { + // Call the API to get a list of all artists + const url = `${config.api.baseUrl}/artists/${isWishList}`; + const response = await fetch(url, { + method: "GET", + headers: apiGetHeaders(), + }); + + const artists = await apiReadResponseData(response, logout); + return artists; +}; + +/** + * Fetch the details for a single artist from the Music Catalogue REST API + * given the artist ID + * @param {*} artistId + * @param {*} logout + * @returns + */ +const apiFetchArtistById = async (artistId, logout) => { + // Call the API to get the artist details + const url = `${config.api.baseUrl}/artists/${artistId}`; + const response = await fetch(url, { + method: "GET", + headers: apiGetHeaders(), + }); + + const artist = await apiReadResponseData(response, logout); + return artist; +}; + +export { apiFetchAllArtists, apiFetchArtistById }; diff --git a/src/music-catalogue-ui/helpers/apiAuthenticate.js b/src/music-catalogue-ui/helpers/apiAuthenticate.js new file mode 100644 index 0000000..c19a506 --- /dev/null +++ b/src/music-catalogue-ui/helpers/apiAuthenticate.js @@ -0,0 +1,32 @@ +import config from "../config.json"; + +/** + * Authenticate with the Music Catalogue REST API + * @param {*} username + * @param {*} password + * @returns + */ +const apiAuthenticate = async (username, password) => { + // Create a JSON body containing the credentials + const body = JSON.stringify({ + userName: username, + password: password, + }); + + // Call the API to authenticate as the specified user and return a token + const url = `${config.api.baseUrl}/users/authenticate/`; + const response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: body, + }); + + // Get the response text + const token = await response.text(); + return token.replace(/"/g, ""); +}; + +export { apiAuthenticate }; diff --git a/src/music-catalogue-ui/helpers/apiDataExchange.js b/src/music-catalogue-ui/helpers/apiDataExchange.js new file mode 100644 index 0000000..eb238f8 --- /dev/null +++ b/src/music-catalogue-ui/helpers/apiDataExchange.js @@ -0,0 +1,31 @@ +import config from "../config.json"; +import { apiGetPostHeaders } from "./apiHeaders"; + +/** + * Request an export of the catalogue + * @param {*} fileName + * @param {*} logout + */ +const apiRequestExport = async (fileName, logout) => { + // Create a JSON body containing the file name to export to + const body = JSON.stringify({ + fileName: fileName, + }); + + // Call the API to request the export + const url = `${config.api.baseUrl}/export/catalogue`; + const response = await fetch(url, { + method: "POST", + headers: apiGetPostHeaders(), + body: body, + }); + + if (response.status == 401) { + // Unauthorized so the token's likely expired - force a login + logout(); + } + + return response.ok; +}; + +export { apiRequestExport }; diff --git a/src/music-catalogue-ui/helpers/apiHeaders.js b/src/music-catalogue-ui/helpers/apiHeaders.js new file mode 100644 index 0000000..1a68841 --- /dev/null +++ b/src/music-catalogue-ui/helpers/apiHeaders.js @@ -0,0 +1,37 @@ +import { apiGetToken } from "./apiToken"; + +/** + * Return the HTTP headers used when sending GET requests to the REST API + * @returns + */ +const apiGetHeaders = () => { + // Get the token + var token = apiGetToken(); + + // Construct the request headers + const headers = { + Authorization: `Bearer ${token}`, + }; + + return headers; +}; + +/** + * Return the HTTP headers used when sending POST and PUT requests to the REST API + * @returns + */ +const apiGetPostHeaders = () => { + // Get the token + var token = apiGetToken(); + + // Construct the request headers + const headers = { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }; + + return headers; +}; + +export { apiGetHeaders, apiGetPostHeaders }; diff --git a/src/music-catalogue-ui/helpers/apiReports.js b/src/music-catalogue-ui/helpers/apiReports.js new file mode 100644 index 0000000..f28727a --- /dev/null +++ b/src/music-catalogue-ui/helpers/apiReports.js @@ -0,0 +1,49 @@ +import config from "../config.json"; +import { apiGetHeaders } from "./apiHeaders"; +import { apiReadResponseData, apiFormatDateTime } from "./apiUtils"; + +/** + * Call the API to retrieve the job status report + * @param {*} from + * @param {*} to + * @param {*} logout + * @returns + */ +const apiJobStatusReport = async (from, to, logout) => { + // Make sure the dates cover the period 00:00:00 on the start date to 23:59:59 on the + // end date + const startDate = new Date( + from.getFullYear(), + from.getMonth(), + from.getDate(), + 0, + 0, + 0 + ); + + const endDate = new Date( + to.getFullYear(), + to.getMonth(), + to.getDate(), + 23, + 59, + 59 + ); + + // Construct the route. Dates need to be formatted in a specific fashion for the API + // to decode them and they also need to be URL encoded + const fromRouteSegment = apiFormatDateTime(startDate); + const toRouteSegment = apiFormatDateTime(endDate); + const url = `${config.api.baseUrl}/reports/jobs/${fromRouteSegment}/${toRouteSegment}`; + console.log(url); + // Call the API to get content for the report + const response = await fetch(url, { + method: "GET", + headers: apiGetHeaders(), + }); + + const records = await apiReadResponseData(response, logout); + return records; +}; + +export { apiJobStatusReport }; diff --git a/src/music-catalogue-ui/helpers/apiRetailers.js b/src/music-catalogue-ui/helpers/apiRetailers.js new file mode 100644 index 0000000..8dee666 --- /dev/null +++ b/src/music-catalogue-ui/helpers/apiRetailers.js @@ -0,0 +1,28 @@ +import config from "../config.json"; +import { apiReadResponseData } from "./apiUtils"; +import { apiGetPostHeaders } from "./apiHeaders"; + +/** + * Create a retailer or return an existing retailer with the specified name + * @param {*} retailerName + * @param {*} logout + * @returns + */ +const apiCreateRetailer = async (retailerName, logout) => { + // Create the request body + const body = JSON.stringify({ name: retailerName }); + + // Call the API to create the retailer. This will just return the current + // record if it already exists + const url = `${config.api.baseUrl}/retailers`; + const response = await fetch(url, { + method: "POST", + headers: apiGetPostHeaders(), + body: body, + }); + + const retailer = await apiReadResponseData(response, logout); + return retailer; +}; + +export { apiCreateRetailer }; diff --git a/src/music-catalogue-ui/helpers/apiToken.js b/src/music-catalogue-ui/helpers/apiToken.js new file mode 100644 index 0000000..163875a --- /dev/null +++ b/src/music-catalogue-ui/helpers/apiToken.js @@ -0,0 +1,33 @@ +/** + * Store the JWT token + * @param {*} token + */ +const apiSetToken = (token) => { + // TODO: Move to HTTP Cookie + localStorage.setItem("token", token); +}; + +/** + * Retrieve the current JWT token + * @param {*} token + * @returns + */ +const apiGetToken = () => { + try { + // TODO: Move to HTTP Cookie + const token = localStorage.getItem("token"); + return token; + } catch { + return null; + } +}; + +/** + * Clear the current JWT token + */ +const apiClearToken = () => { + // TODO: Move to HTTP Cookie + localStorage.removeItem("token"); +}; + +export { apiClearToken, apiSetToken, apiGetToken }; diff --git a/src/music-catalogue-ui/helpers/apiUtils.js b/src/music-catalogue-ui/helpers/apiUtils.js new file mode 100644 index 0000000..062f2cb --- /dev/null +++ b/src/music-catalogue-ui/helpers/apiUtils.js @@ -0,0 +1,54 @@ +/** + * Format a date/time in a manner suitable for use with the API without using + * 3rd party libraries + * @param {F} date + */ +const apiFormatDateTime = (date) => { + // Get the formatted date components + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + + // Get the formatted time components + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + const seconds = date.getSeconds().toString().padStart(2, "0"); + + // Construct and return the formatted date/time + const formattedDate = + year + + "-" + + month + + "-" + + day + + "%20" + + hours + + ":" + + minutes + + ":" + + seconds; + return formattedDate; +}; + +/** + * Read the response content as JSON and return the resulting object + * @param {*} response + * @returns + */ +const apiReadResponseData = async (response, logout) => { + if (response.status == 401) { + // Unauthorized so the token's likely expired - force a login + logout(); + } else if (response.status == 204) { + // If the response is "no content", return NULL + return null; + } else if (response.ok) { + // Get the response content as JSON and return it + const data = await response.json(); + return data; + } else { + return null; + } +}; + +export { apiFormatDateTime, apiReadResponseData }; diff --git a/src/music-catalogue-ui/helpers/navigation.js b/src/music-catalogue-ui/helpers/navigation.js index 686b8b9..15677cc 100644 --- a/src/music-catalogue-ui/helpers/navigation.js +++ b/src/music-catalogue-ui/helpers/navigation.js @@ -7,6 +7,7 @@ const pages = { lookup: "Lookup", export: "Export", jobStatusReport: "JobStatusReport", + albumPurchaseDetails: "AlbumPurchaseDetails", }; export default pages; diff --git a/src/music-catalogue-ui/hooks/useAlbums.js b/src/music-catalogue-ui/hooks/useAlbums.js index 32e1e15..fb61967 100644 --- a/src/music-catalogue-ui/hooks/useAlbums.js +++ b/src/music-catalogue-ui/hooks/useAlbums.js @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { apiFetchAlbumsByArtist } from "@/helpers/api"; +import { apiFetchAlbumsByArtist } from "@/helpers/apiAlbums"; /** * Hook that uses the API helpers to retrieve a list of albums by the specified diff --git a/src/music-catalogue-ui/hooks/useArtists.js b/src/music-catalogue-ui/hooks/useArtists.js index b3f1423..7807b21 100644 --- a/src/music-catalogue-ui/hooks/useArtists.js +++ b/src/music-catalogue-ui/hooks/useArtists.js @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { apiFetchAllArtists } from "@/helpers/api"; +import { apiFetchAllArtists } from "@/helpers/apiArtists"; /** * Hook that uses the API helpers to retrieve a list of artists from the diff --git a/src/music-catalogue-ui/hooks/useIsLoggedIn.js b/src/music-catalogue-ui/hooks/useIsLoggedIn.js index b6784a7..19f8695 100644 --- a/src/music-catalogue-ui/hooks/useIsLoggedIn.js +++ b/src/music-catalogue-ui/hooks/useIsLoggedIn.js @@ -1,4 +1,4 @@ -import { apiGetToken } from "@/helpers/api"; +import { apiGetToken } from "@/helpers/apiToken"; import { useState, useEffect } from "react"; /** diff --git a/src/music-catalogue-ui/hooks/useTracks.js b/src/music-catalogue-ui/hooks/useTracks.js index c6b7b39..3bfa818 100644 --- a/src/music-catalogue-ui/hooks/useTracks.js +++ b/src/music-catalogue-ui/hooks/useTracks.js @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { apiFetchAlbumById } from "@/helpers/api"; +import { apiFetchAlbumById } from "@/helpers/apiAlbums"; /** * Hook that uses the API helpers to retrieve a list of tracks for the @@ -23,7 +23,7 @@ const useTracks = (albumId, logout) => { }; fetchTracks(albumId); - }, []); + }, [albumId, logout]); return { tracks, setTracks }; }; diff --git a/src/music-catalogue-ui/package-lock.json b/src/music-catalogue-ui/package-lock.json index 8a414de..9a6bc9b 100644 --- a/src/music-catalogue-ui/package-lock.json +++ b/src/music-catalogue-ui/package-lock.json @@ -15,6 +15,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "next": "13.5.4", "react": "^18", + "react-currency-input-field": "^3.6.12", "react-datepicker": "^4.21.0", "react-dom": "^18", "react-select": "^5.8.0" @@ -3420,6 +3421,14 @@ "node": ">=0.10.0" } }, + "node_modules/react-currency-input-field": { + "version": "3.6.12", + "resolved": "https://registry.npmjs.org/react-currency-input-field/-/react-currency-input-field-3.6.12.tgz", + "integrity": "sha512-92mVEo1u7tF8Lz5JeaEHpQY/p6ulmnfSk9r3dVMyykQNLoScvgQ7GczvV3uGDr81xkTF3czj7CTJ9Ekqq4+pIA==", + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-datepicker": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-4.21.0.tgz", diff --git a/src/music-catalogue-ui/package.json b/src/music-catalogue-ui/package.json index 2416917..92980d7 100644 --- a/src/music-catalogue-ui/package.json +++ b/src/music-catalogue-ui/package.json @@ -16,6 +16,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "next": "13.5.4", "react": "^18", + "react-currency-input-field": "^3.6.12", "react-datepicker": "^4.21.0", "react-dom": "^18", "react-select": "^5.8.0" diff --git a/wireframes/Album List.csv b/wireframes/Album List.csv index 4af4f09..6a9b169 100644 --- a/wireframes/Album List.csv +++ b/wireframes/Album List.csv @@ -1,2 +1,3 @@ -[Let It Be],The Beatles,Rock & Roll,1970,[Cover] -[The Beatles],The Beatles,Rock & Roll,1968,[Cover] +[Let It Be](Track List),The Beatles,Rock & Roll,1970,01/01/2023,£0.00, HMV, +[The Beatles](Track List),The Beatles,Rock & Roll,1968,01/01/2023,£0.00,Dig Vinyl, +{4L,4L,4L,2L,2L,2L,4L} \ No newline at end of file diff --git a/wireframes/Artist List.csv b/wireframes/Artist List.csv index 21c7079..8ba4ad6 100644 --- a/wireframes/Artist List.csv +++ b/wireframes/Artist List.csv @@ -1,4 +1,4 @@ -[Dire Straits],1,9 -[Dragonette],1,13 -[John Coltrane],1,5 -[The Beatles],2,29 +[Dire Straits](Album List),1,9,£0.00 +[Dragonette](Album List),1,13,£0.00 +[John Coltrane](Album List),1,5,£0.00 +[The Beatles](Album List),2,29,£0.00 diff --git a/wireframes/Example Data.xlsx b/wireframes/Example Data.xlsx index 13447e5..ea69edf 100644 Binary files a/wireframes/Example Data.xlsx and b/wireframes/Example Data.xlsx differ diff --git a/wireframes/Music Catalogue - v10.00.pdf b/wireframes/Music Catalogue - v10.00.pdf new file mode 100644 index 0000000..d200612 Binary files /dev/null and b/wireframes/Music Catalogue - v10.00.pdf differ diff --git a/wireframes/Music Catalogue.bmpr b/wireframes/Music Catalogue.bmpr index 8f9bb3f..5132f24 100644 Binary files a/wireframes/Music Catalogue.bmpr and b/wireframes/Music Catalogue.bmpr differ diff --git a/wireframes/Wish List Album List.csv b/wireframes/Wish List Album List.csv new file mode 100644 index 0000000..f14c64e --- /dev/null +++ b/wireframes/Wish List Album List.csv @@ -0,0 +1,4 @@ +[Glad Rag Doll](Wish List Track List),Diana Krall,Jazz,2012,£0.00,Amazon, +[Live In Paris](Wish List Track List),Diana Krall,Jazz,2002,£0.00,HMV, +[Only Trust Your Heart](Wish List Track List),Diana Krall,Jazz,1995,£0.00,Truck, +{4L,4L,4L,2L,2L,4L} \ No newline at end of file diff --git a/wireframes/Wish List.csv b/wireframes/Wish List.csv new file mode 100644 index 0000000..ac2430f --- /dev/null +++ b/wireframes/Wish List.csv @@ -0,0 +1,3 @@ +[Diana Krall](Wish List Album List),3,37,£0.00 +[George Harrison](Wish List Album List),1,23,£0.00 +[Jane Monheit](Wish List Album List),1,12,£0.00