diff --git a/src/Catalog/Catalog.Api/Catalog.Api.http b/src/Catalog/Catalog.Api/Catalog.Api.http index a900363..5e147dd 100644 --- a/src/Catalog/Catalog.Api/Catalog.Api.http +++ b/src/Catalog/Catalog.Api/Catalog.Api.http @@ -16,6 +16,7 @@ Accept: application/json # "sku": "36606", # "name": "Bubbletron", # "description": "A magical machine that blows out bubbles! Woo!", +# "brand": "ADJ Products LLC", # "createdBy": "Erik" #}' POST http://localhost:5252/product/draft-with-id @@ -27,6 +28,7 @@ Content-Type: application/json "sku": "36606", "name": "Bubbletron", "description": "A magical machine that blows out bubbles! Woo!", + "brand": "ADJ Products LLC", "createdBy": "Erik" } @@ -40,6 +42,7 @@ Content-Type: application/json # "sku": "36606", # "name": "Bubbletron", # "description": "A magical machine that blows out bubbles! Woo!", +# "brand": "ADJ Products LLC", # "createdBy": "Erik" #}' POST http://localhost:5252/product/draft @@ -50,6 +53,7 @@ Content-Type: application/json "sku": "36606", "name": "Bubbletron", "description": "A magical machine that blows out bubbles! Woo!", + "brand": "ADJ Products LLC", "createdBy": "Erik" } @@ -88,9 +92,9 @@ accept: text/plain Content-Type: application/json { - "productId": "36606-001", + "productId": "1243552823715561472", "archivedBy": "Erik", - "reason": "oopsie daisy" + "reason": "Made in error." } ### @@ -111,7 +115,7 @@ Content-Type: application/json { "productId": "36606-001", "cancelledBy": "Erik", - "reason": "not sure tbh frfr" + "reason": "not sure tbh frfr no cap" } ### @@ -152,7 +156,7 @@ Content-Type: application/json { "productId": "36606-001", - "description": "This is a new description and I sure hope it works! Oh, right. Bubbleszzz!", + "description": "This is a new description. Oh, right. Bubblesss! 🫧🫧🫧", "adjustedBy": "Erik" } @@ -165,3 +169,47 @@ GET http://localhost:5252/products/36606-001 accept: text/plain ### +# curl -X 'POST' +# 'http://localhost:5252/product/adjust-brand' +# -H 'accept: text/plain' +# -H 'Content-Type: application/json' +# -d '{ +# "productId": "36606-001", +# "brand": "ADJ Lighting", +# "adjustedBy": "Erik" +#}' +POST http://localhost:5252/product/adjust-brand +accept: text/plain +Content-Type: application/json + +{ + "productId": "36606-001", + "brand": "ADJ Lighting Inc", + "adjustedBy": "Erik" +} + +### + +# curl -X 'POST' +# 'http://localhost:5252/product/take-measurement' +# -H 'accept: text/plain' +# -H 'Content-Type: application/json' +# -d '{ +# "productId": "36606-001", +# "type": "Dimension", +# "unit": "in", +# "value": "14.25 x 7.5 x 8.25" +#}' +POST http://localhost:5252/product/take-measurement +accept: text/plain +Content-Type: application/json + +{ + "productId": "36606-001", + "type": "Dimension", + "unit": "in", + "value": "14.25 x 7.5 x 8.25" +} + +### + diff --git a/src/Catalog/Catalog.Api/Commands/ProductCommandService.cs b/src/Catalog/Catalog.Api/Commands/ProductCommandService.cs index 60676eb..f7ac765 100644 --- a/src/Catalog/Catalog.Api/Commands/ProductCommandService.cs +++ b/src/Catalog/Catalog.Api/Commands/ProductCommandService.cs @@ -1,4 +1,3 @@ -using Catalog.Products; using Ecommerce.Core.Identities; using Eventuous; using static Catalog.Api.Commands.ProductCommands; @@ -24,6 +23,7 @@ public ProductCommandService( cmd.Name, cmd.Description, cmd.Brand, + cmd.Measurements, DateTimeOffset.Now, cmd.CreatedBy, isSkuAvailable, @@ -37,6 +37,7 @@ public ProductCommandService( cmd.Name, cmd.Description, cmd.Brand, + cmd.Measurements, DateTimeOffset.Now, cmd.CreatedBy, isSkuAvailable, @@ -59,16 +60,32 @@ public ProductCommandService( cmd.CancelledBy, cmd.Reason))); + OnExisting(cmd => new ProductId(cmd.ProductId), + ((product, cmd) => product.AdjustDescription( + cmd.Description, + DateTimeOffset.Now, + cmd.AdjustedBy))); + OnExisting(cmd => new ProductId(cmd.ProductId), ((product, cmd) => product.AdjustName( cmd.Name, DateTimeOffset.Now, cmd.AdjustedBy))); - OnExisting(cmd => new ProductId(cmd.ProductId), - ((product, cmd) => product.AdjustDescription( - cmd.Description, + OnExisting(cmd => new ProductId(cmd.ProductId), + ((product, cmd) => product.AdjustBrand( + cmd.Brand, DateTimeOffset.Now, cmd.AdjustedBy))); + + OnExisting(cmd => new ProductId(cmd.ProductId), + ((product, cmd) => product.TakeMeasurement( + Measurement.GetName(cmd.Type), // TODO evaluate if this is a good path to take + cmd.Unit, + cmd.Value))); + + OnExisting(cmd => new ProductId(cmd.ProductId), + ((product, cmd) => product.RemoveMeasurement( + Measurement.GetName(cmd.Type)))); // TODO evaluate if this is a good path to take; } } diff --git a/src/Catalog/Catalog.Api/Commands/ProductCommands.cs b/src/Catalog/Catalog.Api/Commands/ProductCommands.cs index b94e4e0..c95f51e 100644 --- a/src/Catalog/Catalog.Api/Commands/ProductCommands.cs +++ b/src/Catalog/Catalog.Api/Commands/ProductCommands.cs @@ -8,6 +8,7 @@ public record DraftWithProvidedId( string Name, string Description, string Brand, + string Measurements, string CreatedBy ); @@ -16,12 +17,14 @@ public record Draft( string Name, string Description, string Brand, + string Measurements, string CreatedBy ); public record Activate( string ProductId, - string ActivatedBy); + string ActivatedBy + ); public record Archive( string ProductId, @@ -35,15 +38,15 @@ public record Cancel( string Reason ); - public record AdjustName( + public record AdjustDescription( string ProductId, - string Name, + string Description, string AdjustedBy ); - public record AdjustDescription( + public record AdjustName( string ProductId, - string Description, + string Name, string AdjustedBy ); @@ -52,4 +55,16 @@ public record AdjustBrand( string Brand, string AdjustedBy ); + + public record TakeMeasurement( + string ProductId, + string Type, + string Unit, + string Value + ); + + public record RemoveMeasurement( + string ProductId, + string Type + ); } diff --git a/src/Catalog/Catalog.Api/HttpApi/CommandApi.cs b/src/Catalog/Catalog.Api/HttpApi/CommandApi.cs index a9e7447..fa4aa62 100644 --- a/src/Catalog/Catalog.Api/HttpApi/CommandApi.cs +++ b/src/Catalog/Catalog.Api/HttpApi/CommandApi.cs @@ -1,4 +1,3 @@ -using Catalog.Products; using Eventuous; using Eventuous.AspNetCore.Web; using Microsoft.AspNetCore.Mvc; @@ -35,17 +34,27 @@ public Task> Cancel([FromBody] Cancel cmd, CancellationToke => Handle(cmd, ct); [HttpPost] - [Route("adjust-name")] - public Task> AdjustName([FromBody] AdjustName cmd, CancellationToken ct) + [Route("adjust-description")] + public Task> AdjustDescription([FromBody] AdjustDescription cmd, CancellationToken ct) => Handle(cmd, ct); [HttpPost] - [Route("adjust-description")] - public Task> AdjustDescription([FromBody] AdjustDescription cmd, CancellationToken ct) + [Route("adjust-name")] + public Task> AdjustName([FromBody] AdjustName cmd, CancellationToken ct) => Handle(cmd, ct); [HttpPost] [Route("adjust-brand")] public Task> AdjustBrand([FromBody] AdjustBrand cmd, CancellationToken ct) => Handle(cmd, ct); + + [HttpPost] + [Route("take-measurement")] + public Task> TakeMeasurement([FromBody] TakeMeasurement cmd, CancellationToken ct) + => Handle(cmd, ct); + + [HttpPost] + [Route("remove-measurement")] + public Task> RemoveMeasurement([FromBody] RemoveMeasurement cmd, CancellationToken ct) + => Handle(cmd, ct); } diff --git a/src/Catalog/Catalog.Api/HttpApi/QueryApi.cs b/src/Catalog/Catalog.Api/HttpApi/QueryApi.cs index ee45ed7..1829b53 100644 --- a/src/Catalog/Catalog.Api/HttpApi/QueryApi.cs +++ b/src/Catalog/Catalog.Api/HttpApi/QueryApi.cs @@ -1,4 +1,3 @@ -using Catalog.Products; using Eventuous; using Microsoft.AspNetCore.Mvc; diff --git a/src/Catalog/Catalog.Api/Queries/ProductDocument.cs b/src/Catalog/Catalog.Api/Queries/ProductDocument.cs index fbba7b7..87053e9 100644 --- a/src/Catalog/Catalog.Api/Queries/ProductDocument.cs +++ b/src/Catalog/Catalog.Api/Queries/ProductDocument.cs @@ -15,4 +15,7 @@ public ProductDocument(string Id) : base(Id) public string Sku { get; set; } = null!; public string Description { get; set; } = null!; public string Brand { get; set; } = null!; + + public string Measurements { get; set; } = null!; + // public string[] Measurements { get; set; } = null!; // TODO } diff --git a/src/Catalog/Catalog.Api/Queries/ProductStateProjection.cs b/src/Catalog/Catalog.Api/Queries/ProductStateProjection.cs index 870d050..068d661 100644 --- a/src/Catalog/Catalog.Api/Queries/ProductStateProjection.cs +++ b/src/Catalog/Catalog.Api/Queries/ProductStateProjection.cs @@ -1,8 +1,7 @@ -using Catalog.Products; using Eventuous.Projections.MongoDB; using Eventuous.Subscriptions.Context; using MongoDB.Driver; -using static Catalog.Products.ProductEvents; +using static Catalog.ProductEvents; namespace Catalog.Api.Queries; @@ -27,20 +26,21 @@ private static UpdateDefinition Handle( var evt = ctx.Message; return update.SetOnInsert(x => x.Id, ctx.Stream.GetId()) - .Set(x => x.CreatedAt, evt.CreatedAt) - .Set(x => x.CreatedBy, evt.CreatedBy) - .Set(x => x.Status, nameof(ProductStatus.Drafted)) - .Set(x => x.Name, evt.Name) - .Set(x => x.Sku, evt.Sku) - .Set(x => x.Description, evt.Description) - .Set(x => x.Brand, evt.Brand); + .Set(x => x.CreatedAt, evt.CreatedAt) + .Set(x => x.CreatedBy, evt.CreatedBy) + .Set(x => x.Status, nameof(ProductStatus.Drafted)) + .Set(x => x.Name, evt.Name) + .Set(x => x.Sku, evt.Sku) + .Set(x => x.Description, evt.Description) + .Set(x => x.Brand, evt.Brand) + .Set(x => x.Measurements, evt.Measurements); } private static UpdateDefinition Handle( IMessageConsumeContext ctx, UpdateDefinition update) { - var evt = ctx.Message; + var @event = ctx.Message; return update.Set(x => x.Status, nameof(ProductStatus.Activated)); } @@ -49,7 +49,7 @@ private static UpdateDefinition Handle( IMessageConsumeContext ctx, UpdateDefinition update) { - var evt = ctx.Message; + var @event = ctx.Message; return update.Set(x => x.Status, nameof(ProductStatus.Archived)); } @@ -58,7 +58,7 @@ private static UpdateDefinition Handle( IMessageConsumeContext ctx, UpdateDefinition update) { - var evt = ctx.Message; + var @event = ctx.Message; return update.Set(x => x.Status, nameof(ProductStatus.Cancelled)); } @@ -67,26 +67,44 @@ private static UpdateDefinition Handle( IMessageConsumeContext ctx, UpdateDefinition update) { - var evt = ctx.Message; + var @event = ctx.Message; - return update.Set(x => x.Name, evt.Name); + return update.Set(x => x.Name, @event.Name); } private static UpdateDefinition Handle( IMessageConsumeContext ctx, UpdateDefinition update) { - var evt = ctx.Message; + var @event = ctx.Message; - return update.Set(x => x.Description, evt.Description); + return update.Set(x => x.Description, @event.Description); } private static UpdateDefinition Handle( IMessageConsumeContext ctx, UpdateDefinition update) { - var evt = ctx.Message; + var @event = ctx.Message; + + return update.Set(x => x.Brand, @event.Brand); + } + + private static UpdateDefinition Handle( + IMessageConsumeContext ctx, + UpdateDefinition update) + { + var @event = ctx.Message; + + return update.Set(x => x.Measurements, @event.ToString()); + } + + private static UpdateDefinition Handle( + IMessageConsumeContext ctx, + UpdateDefinition update) + { + var @event = ctx.Message; - return update.Set(x => x.Brand, evt.Brand); + return update.Set(x => x.Measurements, string.Empty); } } diff --git a/src/Catalog/Catalog.Api/Registrations.cs b/src/Catalog/Catalog.Api/Registrations.cs index 261a54a..f864d72 100644 --- a/src/Catalog/Catalog.Api/Registrations.cs +++ b/src/Catalog/Catalog.Api/Registrations.cs @@ -2,7 +2,6 @@ using Catalog.Api.Commands; using Catalog.Api.Infrastructure; using Catalog.Api.Queries; -using Catalog.Products; using Ecommerce.Core.Identities; using Eventuous; using Eventuous.Diagnostics.OpenTelemetry; diff --git a/src/Catalog/Catalog/Brand.cs b/src/Catalog/Catalog/Brand.cs index 9018848..3dc7db3 100644 --- a/src/Catalog/Catalog/Brand.cs +++ b/src/Catalog/Catalog/Brand.cs @@ -23,7 +23,7 @@ public Brand(string value) } public bool HasSameValue(string another) - => string.Compare(Value, another, StringComparison.CurrentCulture) != 0; + => string.Compare(Value, another, StringComparison.CurrentCulture) == 0; public static implicit operator string(Brand brand) => brand.Value; diff --git a/src/Catalog/Catalog/Constants.cs b/src/Catalog/Catalog/Constants.cs new file mode 100644 index 0000000..818e6f0 --- /dev/null +++ b/src/Catalog/Catalog/Constants.cs @@ -0,0 +1,14 @@ +namespace Catalog; + +public static class Constants +{ + public static class Measurements + { + public static class Types + { + public const string Dimension = "Dimension"; + public const string Volume = "Volume"; + public const string Weight = "Weight"; + } + } +} diff --git a/src/Catalog/Catalog/Products/Creation.cs b/src/Catalog/Catalog/Creation.cs similarity index 96% rename from src/Catalog/Catalog/Products/Creation.cs rename to src/Catalog/Catalog/Creation.cs index 5f93256..34baba2 100644 --- a/src/Catalog/Catalog/Products/Creation.cs +++ b/src/Catalog/Catalog/Creation.cs @@ -1,6 +1,6 @@ using Eventuous; -namespace Catalog.Products; +namespace Catalog; public record Creation { diff --git a/src/Catalog/Catalog/Products/InternalUserId.cs b/src/Catalog/Catalog/InternalUserId.cs similarity index 93% rename from src/Catalog/Catalog/Products/InternalUserId.cs rename to src/Catalog/Catalog/InternalUserId.cs index 7261d6a..ded50f9 100644 --- a/src/Catalog/Catalog/Products/InternalUserId.cs +++ b/src/Catalog/Catalog/InternalUserId.cs @@ -1,6 +1,6 @@ using Eventuous; -namespace Catalog.Products; +namespace Catalog; public record InternalUserId { @@ -18,10 +18,12 @@ public InternalUserId(string value) if (value.Length > 128) throw new DomainException("Internal user identity cannot exceed 128 characters"); + + Value = value; } public bool HasSameValue(string another) - => string.Compare(Value, another, StringComparison.CurrentCulture) != 0; + => string.Compare(Value, another, StringComparison.CurrentCulture) == 0; public static implicit operator string(InternalUserId internalUserId) => internalUserId.Value; diff --git a/src/Catalog/Catalog/Measurement.cs b/src/Catalog/Catalog/Measurement.cs new file mode 100644 index 0000000..b69a30f --- /dev/null +++ b/src/Catalog/Catalog/Measurement.cs @@ -0,0 +1,83 @@ +using System.Globalization; + +namespace Catalog; + +public record Measurement +{ + public string Type { get; private set; } = default!; + public string Unit { get; private set; } = default!; + public string Value { get; private set; } = default!; + + public MeasurementType GetMeasurementType() => GetName(Type); + + internal Measurement() { } + + public Measurement(MeasurementType type, string unit, string value) + { + Type = GetName(type); + + if (string.IsNullOrWhiteSpace(unit)) + throw new ArgumentNullException(nameof(unit)); + Unit = unit; + + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentNullException(nameof(value)); + Value = value; + } + + public static string GetName(MeasurementType type) => type switch + { + MeasurementType.Unset => throw new ArgumentException("Must set type of measurement"), + MeasurementType.Dimension => Constants.Measurements.Types.Dimension, + MeasurementType.Volume => Constants.Measurements.Types.Volume, + MeasurementType.Weight => Constants.Measurements.Types.Weight, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; + + public static MeasurementType GetName(string type) => type switch + { + "Dimension" => MeasurementType.Dimension, + "Volume" => MeasurementType.Volume, + "Weight" => MeasurementType.Weight, + _ => throw new ArgumentException("Invalid name of measurement type") + }; + + public Measurement (string type, string unit, string value) + : this(GetName(type), unit, value) + { + } + + public void Deconstruct(out string type, out string unit, out string value) + { + type = Type; + unit = Unit; + value = Value; + } + + public bool Matches(Measurement otherMeasurement) => + Type == otherMeasurement.Type && + Unit == otherMeasurement.Unit && + Value == otherMeasurement.Value; + + public bool MatchesTypeAndUnit(Measurement otherMeasurement) => + MatchesType(otherMeasurement.GetMeasurementType()) & + MatchesUnit(otherMeasurement.Unit); + + public bool MatchesType(MeasurementType otherType) => + Type == GetName(otherType); + + public bool MatchesUnit(string otherUnit) => + Unit == otherUnit; + + public bool MatchesValue(string otherValue) => + Value == otherValue; +} + +public enum MeasurementType +{ + Unset = 0, + Dimension = 1, + Volume = 2, + Weight = 3 +} + diff --git a/src/Catalog/Catalog/Name.cs b/src/Catalog/Catalog/Name.cs index e4fa9d2..9b4a289 100644 --- a/src/Catalog/Catalog/Name.cs +++ b/src/Catalog/Catalog/Name.cs @@ -23,7 +23,7 @@ public Name(string value) } public bool HasSameValue(string another) - => string.Compare(Value, another, StringComparison.CurrentCulture) != 0; + => string.Compare(Value, another, StringComparison.CurrentCulture) == 0; public static implicit operator string(Name name) => name.Value; diff --git a/src/Catalog/Catalog/Products/Product.cs b/src/Catalog/Catalog/Product.cs similarity index 82% rename from src/Catalog/Catalog/Products/Product.cs rename to src/Catalog/Catalog/Product.cs index b24a676..24c7e40 100644 --- a/src/Catalog/Catalog/Products/Product.cs +++ b/src/Catalog/Catalog/Product.cs @@ -1,8 +1,8 @@ using Eventuous; -using static Catalog.Products.ProductEvents; -using static Catalog.Products.Services; +using static Catalog.ProductEvents; +using static Catalog.Services; -namespace Catalog.Products; +namespace Catalog; public class Product : Aggregate { @@ -12,6 +12,7 @@ public async Task Draft( string name, string description, string brand, + string measurements, DateTimeOffset createdAt, string createdBy, IsSkuAvailable isSkuAvailable, @@ -28,6 +29,7 @@ public async Task Draft( name, description, brand, + measurements, createdAt, createdBy ) @@ -117,6 +119,32 @@ public void AdjustBrand(string name, DateTimeOffset adjustedAt, string adjustedB ); } + public void TakeMeasurement(MeasurementType type, string unit, string value) + { + EnsureExists(); + + Apply( + new V1.ProductTakeMeasurement( + State.Id.Value, + Measurement.GetName(type), + unit, + value + ) + ); + } + + public void RemoveMeasurement(MeasurementType type) + { + EnsureExists(); + + Apply( + new V1.ProductRemoveMeasurement( + State.Id.Value, + Measurement.GetName(type) + ) + ); + } + private static async Task ValidateSkuAvailability(Sku sku, IsSkuAvailable isSkuAvailable) { var skuAvailable = await isSkuAvailable(sku); diff --git a/src/Catalog/Catalog/Products/ProductEvents.cs b/src/Catalog/Catalog/ProductEvents.cs similarity index 80% rename from src/Catalog/Catalog/Products/ProductEvents.cs rename to src/Catalog/Catalog/ProductEvents.cs index 8ba49ce..d5f03a9 100644 --- a/src/Catalog/Catalog/Products/ProductEvents.cs +++ b/src/Catalog/Catalog/ProductEvents.cs @@ -1,6 +1,6 @@ using Eventuous; -namespace Catalog.Products; +namespace Catalog; public static class ProductEvents { @@ -13,6 +13,7 @@ public record ProductDrafted( string Name, string Description, string Brand, + string Measurements, DateTimeOffset CreatedAt, string CreatedBy ); @@ -62,5 +63,19 @@ public record ProductBrandAdjusted( DateTimeOffset AdjustedAt, string AdjustedBy ); + + [EventType("V1.ProductTakeMeasurement")] + public record ProductTakeMeasurement( + string ProductId, + string Type, + string Unit, + string Value + ); + + [EventType("V1.ProductRemoveMeasurement")] + public record ProductRemoveMeasurement( + string ProductId, + string Type + ); } } diff --git a/src/Catalog/Catalog/Products/ProductId.cs b/src/Catalog/Catalog/ProductId.cs similarity index 71% rename from src/Catalog/Catalog/Products/ProductId.cs rename to src/Catalog/Catalog/ProductId.cs index 2b3afe6..f60cab2 100644 --- a/src/Catalog/Catalog/Products/ProductId.cs +++ b/src/Catalog/Catalog/ProductId.cs @@ -1,5 +1,5 @@ using Eventuous; -namespace Catalog.Products; +namespace Catalog; public record ProductId(string Value) : Id(Value); diff --git a/src/Catalog/Catalog/Products/ProductState.cs b/src/Catalog/Catalog/ProductState.cs similarity index 60% rename from src/Catalog/Catalog/Products/ProductState.cs rename to src/Catalog/Catalog/ProductState.cs index 343de79..234b3b3 100644 --- a/src/Catalog/Catalog/Products/ProductState.cs +++ b/src/Catalog/Catalog/ProductState.cs @@ -1,9 +1,9 @@ +using Ecommerce.Core.Extensions; using Ecommerce.Eventuous.Exceptions; using Eventuous; +using static Catalog.ProductEvents; -using static Catalog.Products.ProductEvents; - -namespace Catalog.Products; +namespace Catalog; public record ProductState : State { @@ -13,15 +13,20 @@ public record ProductState : State public Name Name { get; init; } = null!; public Sku Sku { get; init; } = null!; public Description Description { get; init; } = null!; + public Brand Brand { get; init; } = null!; + public IList Measurements { get; init; } = null!; public ProductState() { On(Handle); - On(Handle); On(Handle); On(Handle); On(Handle); + On(Handle); On(Handle); + On(Handle); + On(Handle); + On(Handle); } private static ProductState Handle( @@ -34,13 +39,15 @@ private static ProductState Handle( Status = ProductStatus.Drafted, Name = new Name(@event.Name), Description = new Description(@event.Description), - Sku = new Sku(@event.Sku) + Sku = new Sku(@event.Sku), + Brand = new Brand(@event.Brand), + Measurements = [] }; private static ProductState Handle(ProductState state, V1.ProductActivated @event) => state.Status switch { - ProductStatus.Archived => throw InvalidStateChangeException.For(state.Id, ProductStatus.Archived), - ProductStatus.Cancelled => throw InvalidStateChangeException.For(state.Id, ProductStatus.Cancelled), + ProductStatus.Archived => throw InvalidStateChangeException.For(state.Id, ProductStatus.Archived), + ProductStatus.Cancelled => throw InvalidStateChangeException.For(state.Id, ProductStatus.Cancelled), _ => state with { Status = ProductStatus.Activated } }; @@ -115,8 +122,70 @@ private static ProductState Handle(ProductState state, V1.ProductNameAdjusted @e // An event store is the source of truth. An audit log. A ledger of transactions. if (state.Name.HasSameValue(adjustedName)) - throw InvalidStateChangeException.For(state.Id, "Incoming name value is the same as current name"); + throw InvalidStateChangeException.For(state.Id, + $"Incoming name value is the same as current name: {state.Name}"); return state with { Name = adjustedName }; } + + private static ProductState Handle(ProductState state, V1.ProductBrandAdjusted @event) + { + if (state.Status is ProductStatus.Closed) + throw InvalidStateChangeException.For(state.Id, state.Status); + + var adjustedBrand = new Brand(@event.Brand); + + if (state.Brand.HasSameValue(adjustedBrand)) + throw InvalidStateChangeException.For(state.Id, + $"Incoming brand value is the same as current brand: {state.Brand}"); + + return state with { Brand = adjustedBrand }; + } + + private static ProductState Handle(ProductState state, V1.ProductTakeMeasurement @event) + { + if (state.Status is ProductStatus.Closed) + throw InvalidStateChangeException.For(state.Id, state.Status); + + var incoming = new Measurement(@event.Type, @event.Unit, @event.Value); + var exists = FindMeasurementTypeMatchingWith(state.Measurements, incoming.GetMeasurementType()); + + if (exists is not null) + { + if (exists.Matches(incoming)) + return state; // no-op if everything matches existing measurements properties (type, unit, value) + + if (exists.MatchesTypeAndUnit(incoming)) + { + state.Measurements.Replace(exists, incoming); + return state; // mutated state's Measurements by replacing the existing Measurement with its new value + } + } + + // if the measurement type is not in the existing state, add it // TODO: tests + state.Measurements.Add(incoming); + return state; + } + + private static ProductState Handle(ProductState state, V1.ProductRemoveMeasurement @event) + { + if (state.Status is ProductStatus.Closed) + throw InvalidStateChangeException.For(state.Id, state.Status); + + var typeToRemove = Measurement.GetName(@event.Type); + var exists = FindMeasurementTypeMatchingWith(state.Measurements, typeToRemove); + + if (exists is null) + return state; // no-op as the type does not exist (Also, how did we get to this logic branch?) + + var existing = state.Measurements.First(m => m.MatchesType(typeToRemove)); + state.Measurements.Remove(existing); + return state; // mutated state's Measurements by removing the corresponding Measurement by its type property + } + + private static Measurement? FindMeasurementMatchingWith(IList measurements, Measurement measurement) => + measurements.SingleOrDefault(m => m.Matches(measurement)); + + private static Measurement? FindMeasurementTypeMatchingWith(IList measurements, MeasurementType measurementType) => + measurements.SingleOrDefault(m => m.MatchesType(measurementType)); } diff --git a/src/Catalog/Catalog/Products/ProductStatus.cs b/src/Catalog/Catalog/ProductStatus.cs similarity index 84% rename from src/Catalog/Catalog/Products/ProductStatus.cs rename to src/Catalog/Catalog/ProductStatus.cs index c191c19..5ea3285 100644 --- a/src/Catalog/Catalog/Products/ProductStatus.cs +++ b/src/Catalog/Catalog/ProductStatus.cs @@ -1,4 +1,4 @@ -namespace Catalog.Products; +namespace Catalog; public enum ProductStatus { diff --git a/src/Catalog/Catalog/Reason.cs b/src/Catalog/Catalog/Reason.cs index 61d3407..03b5950 100644 --- a/src/Catalog/Catalog/Reason.cs +++ b/src/Catalog/Catalog/Reason.cs @@ -1,3 +1,22 @@ +using Eventuous; + namespace Catalog; -public record Reason(); +public record Reason +{ + public string Value { get; internal init; } = string.Empty; + + internal Reason() { } + + public Reason(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new DomainException("Reason value cannot be empty"); + + Value = value; + } + + public bool HasSameValue(Reason another) => Value == another.Value; + + public static implicit operator string(Reason reason) => reason.Value; +} diff --git a/src/Catalog/Catalog/Products/Services.cs b/src/Catalog/Catalog/Services.cs similarity index 86% rename from src/Catalog/Catalog/Products/Services.cs rename to src/Catalog/Catalog/Services.cs index 1dd8abd..1493119 100644 --- a/src/Catalog/Catalog/Products/Services.cs +++ b/src/Catalog/Catalog/Services.cs @@ -1,4 +1,4 @@ -namespace Catalog.Products; +namespace Catalog; public static class Services { diff --git a/src/Catalog/Catalog/Sku.cs b/src/Catalog/Catalog/Sku.cs index fd95662..5beb9f2 100644 --- a/src/Catalog/Catalog/Sku.cs +++ b/src/Catalog/Catalog/Sku.cs @@ -12,6 +12,8 @@ public Sku(string value) { if (string.IsNullOrWhiteSpace(value)) throw new DomainException("SKU value cannot be empty"); + + Value = value; } public bool HasSameValue(Sku another) => Value == another.Value; diff --git a/src/Core/Ecommerce.Core/Extensions/ListExtensions.cs b/src/Core/Ecommerce.Core/Extensions/ListExtensions.cs new file mode 100644 index 0000000..b1e80ba --- /dev/null +++ b/src/Core/Ecommerce.Core/Extensions/ListExtensions.cs @@ -0,0 +1,16 @@ +namespace Ecommerce.Core.Extensions; + +public static class ListExtensions +{ + public static IList Replace(this IList list, T existingElement, T replacement) + { + var indexOfExistingItem = list.IndexOf(existingElement); + + if (indexOfExistingItem == -1) + throw new ArgumentOutOfRangeException(nameof(existingElement), "Element was not found"); + + list[indexOfExistingItem] = replacement; + + return list; + } +}