From 7215eebb5a576d8b213ecb773051455b7756fe65 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 14 May 2026 14:32:19 +0200 Subject: [PATCH] A lot of changes --- .agents/skills/csharp-async/SKILL.md | 49 ++ .agents/skills/csharp-docs/SKILL.md | 62 +++ .agents/skills/csharp-mstest/SKILL.md | 478 ++++++++++++++++ .agents/skills/csharp-nunit/SKILL.md | 71 +++ .agents/skills/csharp-tunit/SKILL.md | 100 ++++ .agents/skills/csharp-xunit/SKILL.md | 68 +++ .agents/skills/dotnet-best-practices/SKILL.md | 85 +++ .../dotnet-design-pattern-review/SKILL.md | 42 ++ .agents/skills/dotnet-upgrade/SKILL.md | 116 ++++ .../Abstractions/Infrastructure/IEventBus.cs | 2 +- .../Infrastructure/IStorageService.cs | 2 + .../Configuration/PlayListConfiguration.cs | 34 +- .../Configuration/TrackConfiguration.cs | 44 +- .../Commands/CreatePlayListCommand.cs | 22 +- .../RequestPlayListPictureUploadCommand.cs | 12 + .../Commands/UpdatePlayListCommand.cs | 3 +- .../Handlers/CreatePlayListCommandHandler.cs | 98 +--- .../Handlers/DeletePlayListCommandHandler.cs | 62 ++- ...uestPlayListPictureUploadCommandHandler.cs | 88 +++ .../Handlers/UpdatePlayListCommandHandler.cs | 151 +----- .../PlayListPictureUploadResponse.cs | 10 + .../Tracks/Commands/CreateTrackCommand.cs | 23 +- .../Commands/RequestTrackUploadUrlsCommand.cs | 14 + .../Handler/CreateTrackCommandHandler.cs | 137 ++--- .../Handler/DeleteTrackCommandHandler.cs | 40 +- .../RequestTrackUploadUrlsCommandHandler.cs | 110 ++++ .../Responses/TrackUploadUrlsResponse.cs | 14 + Infrastructure/Infrastructure.csproj | 1 + .../Files/UploadFileToBucketActivity.cs | 7 +- .../Activities/Tracks/DeleteTrackActivity.cs | 12 +- ...assTransitDependencyInjection.Endpoints.cs | 51 +- .../MassTransitDependencyInjection.cs | 19 + .../EventBus/MassTransitEventBus.cs | 2 +- .../PictureWorkflowRoutingSlipBuilder.cs | 2 +- Infrastructure/Persistence/Database.cs | 10 + ...514095113_AddMassTransitOutbox.Designer.cs | 512 ++++++++++++++++++ .../20260514095113_AddMassTransitOutbox.cs | 142 +++++ .../Migrations/DatabaseModelSnapshot.cs | 180 ++++++ Infrastructure/Services/StorageService.cs | 25 +- docs/changes-summary.md | 165 ++++++ docs/delete-workflows-plan.md | 175 ++++++ docs/presigned-upload-hardening.md | 95 ++++ skills-lock.json | 50 ++ 43 files changed, 2941 insertions(+), 444 deletions(-) create mode 100644 .agents/skills/csharp-async/SKILL.md create mode 100644 .agents/skills/csharp-docs/SKILL.md create mode 100644 .agents/skills/csharp-mstest/SKILL.md create mode 100644 .agents/skills/csharp-nunit/SKILL.md create mode 100644 .agents/skills/csharp-tunit/SKILL.md create mode 100644 .agents/skills/csharp-xunit/SKILL.md create mode 100644 .agents/skills/dotnet-best-practices/SKILL.md create mode 100644 .agents/skills/dotnet-design-pattern-review/SKILL.md create mode 100644 .agents/skills/dotnet-upgrade/SKILL.md create mode 100644 Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs create mode 100644 Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs create mode 100644 Application/PlayLists/Responses/PlayListPictureUploadResponse.cs create mode 100644 Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs create mode 100644 Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs create mode 100644 Application/Tracks/Responses/TrackUploadUrlsResponse.cs create mode 100644 Infrastructure/Persistence/Migrations/20260514095113_AddMassTransitOutbox.Designer.cs create mode 100644 Infrastructure/Persistence/Migrations/20260514095113_AddMassTransitOutbox.cs create mode 100644 docs/changes-summary.md create mode 100644 docs/delete-workflows-plan.md create mode 100644 docs/presigned-upload-hardening.md create mode 100644 skills-lock.json diff --git a/.agents/skills/csharp-async/SKILL.md b/.agents/skills/csharp-async/SKILL.md new file mode 100644 index 0000000..4dbe78b --- /dev/null +++ b/.agents/skills/csharp-async/SKILL.md @@ -0,0 +1,49 @@ +--- +name: csharp-async +description: 'Get best practices for C# async programming' +--- + +# C# Async Programming Best Practices + +Your goal is to help me follow best practices for asynchronous programming in C#. + +## Naming Conventions + +- Use the 'Async' suffix for all async methods +- Match method names with their synchronous counterparts when applicable (e.g., `GetDataAsync()` for `GetData()`) + +## Return Types + +- Return `Task` when the method returns a value +- Return `Task` when the method doesn't return a value +- Consider `ValueTask` for high-performance scenarios to reduce allocations +- Avoid returning `void` for async methods except for event handlers + +## Exception Handling + +- Use try/catch blocks around await expressions +- Avoid swallowing exceptions in async methods +- Use `ConfigureAwait(false)` when appropriate to prevent deadlocks in library code +- Propagate exceptions with `Task.FromException()` instead of throwing in async Task returning methods + +## Performance + +- Use `Task.WhenAll()` for parallel execution of multiple tasks +- Use `Task.WhenAny()` for implementing timeouts or taking the first completed task +- Avoid unnecessary async/await when simply passing through task results +- Consider cancellation tokens for long-running operations + +## Common Pitfalls + +- Never use `.Wait()`, `.Result`, or `.GetAwaiter().GetResult()` in async code +- Avoid mixing blocking and async code +- Don't create async void methods (except for event handlers) +- Always await Task-returning methods + +## Implementation Patterns + +- Implement the async command pattern for long-running operations +- Use async streams (IAsyncEnumerable) for processing sequences asynchronously +- Consider the task-based asynchronous pattern (TAP) for public APIs + +When reviewing my C# code, identify these issues and suggest improvements that follow these best practices. diff --git a/.agents/skills/csharp-docs/SKILL.md b/.agents/skills/csharp-docs/SKILL.md new file mode 100644 index 0000000..6c67306 --- /dev/null +++ b/.agents/skills/csharp-docs/SKILL.md @@ -0,0 +1,62 @@ +--- +name: csharp-docs +description: 'Ensure that C# types are documented with XML comments and follow best practices for documentation.' +--- + +# C# Documentation Best Practices + +- Public members should be documented with XML comments. +- It is encouraged to document internal members as well, especially if they are complex or not self-explanatory. + +## Guidance for all APIs + +- Use `` to provide a brief, one sentence, description of what the type or member does. Start the summary with a present-tense, third-person verb. +- Use `` for additional information, which can include implementation details, usage notes, or any other relevant context. +- Use `` for language-specific keywords like `null`, `true`, `false`, `int`, `bool`, etc. +- Use `` for inline code snippets. +- Use `` for usage examples on how to use the member. + - Use `` for code blocks. `` tags should be placed within an `` tag. Add the language of the code example using the `language` attribute, for example, ``. +- Use `` to reference other types or members inline (in a sentence). +- Use `` for standalone (not in a sentence) references to other types or members in the "See also" section of the online docs. +- Use `` to inherit documentation from base classes or interfaces. + - Unless there is major behavior change, in which case you should document the differences. + +## Methods + +- Use `` to describe method parameters. + - The description should be a noun phrase that doesn't specify the data type. + - Begin with an introductory article. + - If the parameter is a flag enum, start the description with "A bitwise combination of the enumeration values that specifies...". + - If the parameter is a non-flag enum, start the description with "One of the enumeration values that specifies...". + - If the parameter is a Boolean, the wording should be of the form "`` to ...; otherwise, ``.". + - If the parameter is an "out" parameter, the wording should be of the form "When this method returns, contains .... This parameter is treated as uninitialized.". +- Use `` to reference parameter names in documentation. +- Use `` to describe type parameters in generic types or methods. +- Use `` to reference type parameters in documentation. +- Use `` to describe what the method returns. + - The description should be a noun phrase that doesn't specify the data type. + - Begin with an introductory article. + - If the return type is Boolean, the wording should be of the form "`` if ...; otherwise, ``.". + +## Constructors + +- The summary wording should be "Initializes a new instance of the class [or struct].". + +## Properties + +- The `` should start with: + - "Gets or sets..." for a read-write property. + - "Gets..." for a read-only property. + - "Gets [or sets] a value that indicates whether..." for properties that return a Boolean value. +- Use `` to describe the value of the property. + - The description should be a noun phrase that doesn't specify the data type. + - If the property has a default value, add it in a separate sentence, for example, "The default is ``". + - If the value type is Boolean, the wording should be of the form "`` if ...; otherwise, ``. The default is ...". + +## Exceptions + +- Use `` to document exceptions thrown by constructors, properties, indexers, methods, operators, and events. +- Document all exceptions thrown directly by the member. +- For exceptions thrown by nested members, document only the exceptions users are most likely to encounter. +- The description of the exception describes the condition under which it's thrown. + - Omit "Thrown if ..." or "If ..." at the beginning of the sentence. Just state the condition directly, for example "An error occurred when accessing a Message Queuing API." diff --git a/.agents/skills/csharp-mstest/SKILL.md b/.agents/skills/csharp-mstest/SKILL.md new file mode 100644 index 0000000..e68bc31 --- /dev/null +++ b/.agents/skills/csharp-mstest/SKILL.md @@ -0,0 +1,478 @@ +--- +name: csharp-mstest +description: 'Get best practices for MSTest 3.x/4.x unit testing, including modern assertion APIs and data-driven tests' +--- + +# MSTest Best Practices (MSTest 3.x/4.x) + +Your goal is to help me write effective unit tests with modern MSTest, using current APIs and best practices. + +## Project Setup + +- Use a separate test project with naming convention `[ProjectName].Tests` +- Reference MSTest 3.x+ NuGet packages (includes analyzers) +- Consider using MSTest.Sdk for simplified project setup +- Run tests with `dotnet test` + +## Test Class Structure + +- Use `[TestClass]` attribute for test classes +- **Seal test classes by default** for performance and design clarity +- Use `[TestMethod]` for test methods (prefer over `[DataTestMethod]`) +- Follow Arrange-Act-Assert (AAA) pattern +- Name tests using pattern `MethodName_Scenario_ExpectedBehavior` + +```csharp +[TestClass] +public sealed class CalculatorTests +{ + [TestMethod] + public void Add_TwoPositiveNumbers_ReturnsSum() + { + // Arrange + var calculator = new Calculator(); + + // Act + var result = calculator.Add(2, 3); + + // Assert + Assert.AreEqual(5, result); + } +} +``` + +## Test Lifecycle + +- **Prefer constructors over `[TestInitialize]`** - enables `readonly` fields and follows standard C# patterns +- Use `[TestCleanup]` for cleanup that must run even if test fails +- Combine constructor with async `[TestInitialize]` when async setup is needed + +```csharp +[TestClass] +public sealed class ServiceTests +{ + private readonly MyService _service; // readonly enabled by constructor + + public ServiceTests() + { + _service = new MyService(); + } + + [TestInitialize] + public async Task InitAsync() + { + // Use for async initialization only + await _service.WarmupAsync(); + } + + [TestCleanup] + public void Cleanup() => _service.Reset(); +} +``` + +### Execution Order + +1. **Assembly Initialization** - `[AssemblyInitialize]` (once per test assembly) +2. **Class Initialization** - `[ClassInitialize]` (once per test class) +3. **Test Initialization** (for every test method): + 1. Constructor + 2. Set `TestContext` property + 3. `[TestInitialize]` +4. **Test Execution** - test method runs +5. **Test Cleanup** (for every test method): + 1. `[TestCleanup]` + 2. `DisposeAsync` (if implemented) + 3. `Dispose` (if implemented) +6. **Class Cleanup** - `[ClassCleanup]` (once per test class) +7. **Assembly Cleanup** - `[AssemblyCleanup]` (once per test assembly) + +## Modern Assertion APIs + +MSTest provides three assertion classes: `Assert`, `StringAssert`, and `CollectionAssert`. + +### Assert Class - Core Assertions + +```csharp +// Equality +Assert.AreEqual(expected, actual); +Assert.AreNotEqual(notExpected, actual); +Assert.AreSame(expectedObject, actualObject); // Reference equality +Assert.AreNotSame(notExpectedObject, actualObject); + +// Null checks +Assert.IsNull(value); +Assert.IsNotNull(value); + +// Boolean +Assert.IsTrue(condition); +Assert.IsFalse(condition); + +// Fail/Inconclusive +Assert.Fail("Test failed due to..."); +Assert.Inconclusive("Test cannot be completed because..."); +``` + +### Exception Testing (Prefer over `[ExpectedException]`) + +```csharp +// Assert.Throws - matches TException or derived types +var ex = Assert.Throws(() => Method(null)); +Assert.AreEqual("Value cannot be null.", ex.Message); + +// Assert.ThrowsExactly - matches exact type only +var ex = Assert.ThrowsExactly(() => Method()); + +// Async versions +var ex = await Assert.ThrowsAsync(async () => await client.GetAsync(url)); +var ex = await Assert.ThrowsExactlyAsync(async () => await Method()); +``` + +### Collection Assertions (Assert class) + +```csharp +Assert.Contains(expectedItem, collection); +Assert.DoesNotContain(unexpectedItem, collection); +Assert.ContainsSingle(collection); // exactly one element +Assert.HasCount(5, collection); +Assert.IsEmpty(collection); +Assert.IsNotEmpty(collection); +``` + +### String Assertions (Assert class) + +```csharp +Assert.Contains("expected", actualString); +Assert.StartsWith("prefix", actualString); +Assert.EndsWith("suffix", actualString); +Assert.DoesNotStartWith("prefix", actualString); +Assert.DoesNotEndWith("suffix", actualString); +Assert.MatchesRegex(@"\d{3}-\d{4}", phoneNumber); +Assert.DoesNotMatchRegex(@"\d+", textOnly); +``` + +### Comparison Assertions + +```csharp +Assert.IsGreaterThan(lowerBound, actual); +Assert.IsGreaterThanOrEqualTo(lowerBound, actual); +Assert.IsLessThan(upperBound, actual); +Assert.IsLessThanOrEqualTo(upperBound, actual); +Assert.IsInRange(actual, low, high); +Assert.IsPositive(number); +Assert.IsNegative(number); +``` + +### Type Assertions + +```csharp +// MSTest 3.x - uses out parameter +Assert.IsInstanceOfType(obj, out var typed); +typed.DoSomething(); + +// MSTest 4.x - returns typed result directly +var typed = Assert.IsInstanceOfType(obj); +typed.DoSomething(); + +Assert.IsNotInstanceOfType(obj); +``` + +### Assert.That (MSTest 4.0+) + +```csharp +Assert.That(result.Count > 0); // Auto-captures expression in failure message +``` + +### StringAssert Class + +> **Note:** Prefer `Assert` class equivalents when available (e.g., `Assert.Contains("expected", actual)` over `StringAssert.Contains(actual, "expected")`). + +```csharp +StringAssert.Contains(actualString, "expected"); +StringAssert.StartsWith(actualString, "prefix"); +StringAssert.EndsWith(actualString, "suffix"); +StringAssert.Matches(actualString, new Regex(@"\d{3}-\d{4}")); +StringAssert.DoesNotMatch(actualString, new Regex(@"\d+")); +``` + +### CollectionAssert Class + +> **Note:** Prefer `Assert` class equivalents when available (e.g., `Assert.Contains`). + +```csharp +// Containment +CollectionAssert.Contains(collection, expectedItem); +CollectionAssert.DoesNotContain(collection, unexpectedItem); + +// Equality (same elements, same order) +CollectionAssert.AreEqual(expectedCollection, actualCollection); +CollectionAssert.AreNotEqual(unexpectedCollection, actualCollection); + +// Equivalence (same elements, any order) +CollectionAssert.AreEquivalent(expectedCollection, actualCollection); +CollectionAssert.AreNotEquivalent(unexpectedCollection, actualCollection); + +// Subset checks +CollectionAssert.IsSubsetOf(subset, superset); +CollectionAssert.IsNotSubsetOf(notSubset, collection); + +// Element validation +CollectionAssert.AllItemsAreInstancesOfType(collection, typeof(MyClass)); +CollectionAssert.AllItemsAreNotNull(collection); +CollectionAssert.AllItemsAreUnique(collection); +``` + +## Data-Driven Tests + +### DataRow + +```csharp +[TestMethod] +[DataRow(1, 2, 3)] +[DataRow(0, 0, 0, DisplayName = "Zeros")] +[DataRow(-1, 1, 0, IgnoreMessage = "Known issue #123")] // MSTest 3.8+ +public void Add_ReturnsSum(int a, int b, int expected) +{ + Assert.AreEqual(expected, Calculator.Add(a, b)); +} +``` + +### DynamicData + +The data source can return any of the following types: + +- `IEnumerable<(T1, T2, ...)>` (ValueTuple) - **preferred**, provides type safety (MSTest 3.7+) +- `IEnumerable>` - provides type safety +- `IEnumerable` - provides type safety plus control over test metadata (display name, categories) +- `IEnumerable` - **least preferred**, no type safety + +> **Note:** When creating new test data methods, prefer `ValueTuple` or `TestDataRow` over `IEnumerable`. The `object[]` approach provides no compile-time type checking and can lead to runtime errors from type mismatches. + +```csharp +[TestMethod] +[DynamicData(nameof(TestData))] +public void DynamicTest(int a, int b, int expected) +{ + Assert.AreEqual(expected, Calculator.Add(a, b)); +} + +// ValueTuple - preferred (MSTest 3.7+) +public static IEnumerable<(int a, int b, int expected)> TestData => +[ + (1, 2, 3), + (0, 0, 0), +]; + +// TestDataRow - when you need custom display names or metadata +public static IEnumerable> TestDataWithMetadata => +[ + new((1, 2, 3)) { DisplayName = "Positive numbers" }, + new((0, 0, 0)) { DisplayName = "Zeros" }, + new((-1, 1, 0)) { DisplayName = "Mixed signs", IgnoreMessage = "Known issue #123" }, +]; + +// IEnumerable - avoid for new code (no type safety) +public static IEnumerable LegacyTestData => +[ + [1, 2, 3], + [0, 0, 0], +]; +``` + +## TestContext + +The `TestContext` class provides test run information, cancellation support, and output methods. +See [TestContext documentation](https://learn.microsoft.com/dotnet/core/testing/unit-testing-mstest-writing-tests-testcontext) for complete reference. + +### Accessing TestContext + +```csharp +// Property (MSTest suppresses CS8618 - don't use nullable or = null!) +public TestContext TestContext { get; set; } + +// Constructor injection (MSTest 3.6+) - preferred for immutability +[TestClass] +public sealed class MyTests +{ + private readonly TestContext _testContext; + + public MyTests(TestContext testContext) + { + _testContext = testContext; + } +} + +// Static methods receive it as parameter +[ClassInitialize] +public static void ClassInit(TestContext context) { } + +// Optional for cleanup methods (MSTest 3.6+) +[ClassCleanup] +public static void ClassCleanup(TestContext context) { } + +[AssemblyCleanup] +public static void AssemblyCleanup(TestContext context) { } +``` + +### Cancellation Token + +Always use `TestContext.CancellationToken` for cooperative cancellation with `[Timeout]`: + +```csharp +[TestMethod] +[Timeout(5000)] +public async Task LongRunningTest() +{ + await _httpClient.GetAsync(url, TestContext.CancellationToken); +} +``` + +### Test Run Properties + +```csharp +TestContext.TestName // Current test method name +TestContext.TestDisplayName // Display name (3.7+) +TestContext.CurrentTestOutcome // Pass/Fail/InProgress +TestContext.TestData // Parameterized test data (3.7+, in TestInitialize/Cleanup) +TestContext.TestException // Exception if test failed (3.7+, in TestCleanup) +TestContext.DeploymentDirectory // Directory with deployment items +``` + +### Output and Result Files + +```csharp +// Write to test output (useful for debugging) +TestContext.WriteLine("Processing item {0}", itemId); + +// Attach files to test results (logs, screenshots) +TestContext.AddResultFile(screenshotPath); + +// Store/retrieve data across test methods +TestContext.Properties["SharedKey"] = computedValue; +``` + +## Advanced Features + +### Retry for Flaky Tests (MSTest 3.9+) + +```csharp +[TestMethod] +[Retry(3)] +public void FlakyTest() { } +``` + +### Conditional Execution (MSTest 3.10+) + +Skip or run tests based on OS or CI environment: + +```csharp +// OS-specific tests +[TestMethod] +[OSCondition(OperatingSystems.Windows)] +public void WindowsOnlyTest() { } + +[TestMethod] +[OSCondition(OperatingSystems.Linux | OperatingSystems.MacOS)] +public void UnixOnlyTest() { } + +[TestMethod] +[OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)] +public void SkipOnWindowsTest() { } + +// CI environment tests +[TestMethod] +[CICondition] // Runs only in CI (default: ConditionMode.Include) +public void CIOnlyTest() { } + +[TestMethod] +[CICondition(ConditionMode.Exclude)] // Skips in CI, runs locally +public void LocalOnlyTest() { } +``` + +### Parallelization + +```csharp +// Assembly level +[assembly: Parallelize(Workers = 4, Scope = ExecutionScope.MethodLevel)] + +// Disable for specific class +[TestClass] +[DoNotParallelize] +public sealed class SequentialTests { } +``` + +### Work Item Traceability (MSTest 3.8+) + +Link tests to work items for traceability in test reports: + +```csharp +// Azure DevOps work items +[TestMethod] +[WorkItem(12345)] // Links to work item #12345 +public void Feature_Scenario_ExpectedBehavior() { } + +// Multiple work items +[TestMethod] +[WorkItem(12345)] +[WorkItem(67890)] +public void Feature_CoversMultipleRequirements() { } + +// GitHub issues (MSTest 3.8+) +[TestMethod] +[GitHubWorkItem("https://github.com/owner/repo/issues/42")] +public void BugFix_Issue42_IsResolved() { } +``` + +Work item associations appear in test results and can be used for: +- Tracing test coverage to requirements +- Linking bug fixes to regression tests +- Generating traceability reports in CI/CD pipelines + +## Common Mistakes to Avoid + +```csharp +// ❌ Wrong argument order +Assert.AreEqual(actual, expected); +// ✅ Correct +Assert.AreEqual(expected, actual); + +// ❌ Using ExpectedException (obsolete) +[ExpectedException(typeof(ArgumentException))] +// ✅ Use Assert.Throws +Assert.Throws(() => Method()); + +// ❌ Using LINQ Single() - unclear exception +var item = items.Single(); +// ✅ Use ContainsSingle - better failure message +var item = Assert.ContainsSingle(items); + +// ❌ Hard cast - unclear exception +var handler = (MyHandler)result; +// ✅ Type assertion - shows actual type on failure +var handler = Assert.IsInstanceOfType(result); + +// ❌ Ignoring cancellation token +await client.GetAsync(url, CancellationToken.None); +// ✅ Flow test cancellation +await client.GetAsync(url, TestContext.CancellationToken); + +// ❌ Making TestContext nullable - leads to unnecessary null checks +public TestContext? TestContext { get; set; } +// ❌ Using null! - MSTest already suppresses CS8618 for this property +public TestContext TestContext { get; set; } = null!; +// ✅ Declare without nullable or initializer - MSTest handles the warning +public TestContext TestContext { get; set; } +``` + +## Test Organization + +- Group tests by feature or component +- Use `[TestCategory("Category")]` for filtering +- Use `[TestProperty("Name", "Value")]` for custom metadata (e.g., `[TestProperty("Bug", "12345")]`) +- Use `[Priority(1)]` for critical tests +- Enable relevant MSTest analyzers (MSTEST0020 for constructor preference) + +## Mocking and Isolation + +- Use Moq or NSubstitute for mocking dependencies +- Use interfaces to facilitate mocking +- Mock dependencies to isolate units under test diff --git a/.agents/skills/csharp-nunit/SKILL.md b/.agents/skills/csharp-nunit/SKILL.md new file mode 100644 index 0000000..7890775 --- /dev/null +++ b/.agents/skills/csharp-nunit/SKILL.md @@ -0,0 +1,71 @@ +--- +name: csharp-nunit +description: 'Get best practices for NUnit unit testing, including data-driven tests' +--- + +# NUnit Best Practices + +Your goal is to help me write effective unit tests with NUnit, covering both standard and data-driven testing approaches. + +## Project Setup + +- Use a separate test project with naming convention `[ProjectName].Tests` +- Reference Microsoft.NET.Test.Sdk, NUnit, and NUnit3TestAdapter packages +- Create test classes that match the classes being tested (e.g., `CalculatorTests` for `Calculator`) +- Use .NET SDK test commands: `dotnet test` for running tests + +## Test Structure + +- Apply `[TestFixture]` attribute to test classes +- Use `[Test]` attribute for test methods +- Follow the Arrange-Act-Assert (AAA) pattern +- Name tests using the pattern `MethodName_Scenario_ExpectedBehavior` +- Use `[SetUp]` and `[TearDown]` for per-test setup and teardown +- Use `[OneTimeSetUp]` and `[OneTimeTearDown]` for per-class setup and teardown +- Use `[SetUpFixture]` for assembly-level setup and teardown + +## Standard Tests + +- Keep tests focused on a single behavior +- Avoid testing multiple behaviors in one test method +- Use clear assertions that express intent +- Include only the assertions needed to verify the test case +- Make tests independent and idempotent (can run in any order) +- Avoid test interdependencies + +## Data-Driven Tests + +- Use `[TestCase]` for inline test data +- Use `[TestCaseSource]` for programmatically generated test data +- Use `[Values]` for simple parameter combinations +- Use `[ValueSource]` for property or method-based data sources +- Use `[Random]` for random numeric test values +- Use `[Range]` for sequential numeric test values +- Use `[Combinatorial]` or `[Pairwise]` for combining multiple parameters + +## Assertions + +- Use `Assert.That` with constraint model (preferred NUnit style) +- Use constraints like `Is.EqualTo`, `Is.SameAs`, `Contains.Item` +- Use `Assert.AreEqual` for simple value equality (classic style) +- Use `CollectionAssert` for collection comparisons +- Use `StringAssert` for string-specific assertions +- Use `Assert.Throws` or `Assert.ThrowsAsync` to test exceptions +- Use descriptive messages in assertions for clarity on failure + +## Mocking and Isolation + +- Consider using Moq or NSubstitute alongside NUnit +- Mock dependencies to isolate units under test +- Use interfaces to facilitate mocking +- Consider using a DI container for complex test setups + +## Test Organization + +- Group tests by feature or component +- Use categories with `[Category("CategoryName")]` +- Use `[Order]` to control test execution order when necessary +- Use `[Author("DeveloperName")]` to indicate ownership +- Use `[Description]` to provide additional test information +- Consider `[Explicit]` for tests that shouldn't run automatically +- Use `[Ignore("Reason")]` to temporarily skip tests diff --git a/.agents/skills/csharp-tunit/SKILL.md b/.agents/skills/csharp-tunit/SKILL.md new file mode 100644 index 0000000..c972ebe --- /dev/null +++ b/.agents/skills/csharp-tunit/SKILL.md @@ -0,0 +1,100 @@ +--- +name: csharp-tunit +description: 'Get best practices for TUnit unit testing, including data-driven tests' +--- + +# TUnit Best Practices + +Your goal is to help me write effective unit tests with TUnit, covering both standard and data-driven testing approaches. + +## Project Setup + +- Use a separate test project with naming convention `[ProjectName].Tests` +- Reference TUnit package and TUnit.Assertions for fluent assertions +- Create test classes that match the classes being tested (e.g., `CalculatorTests` for `Calculator`) +- Use .NET SDK test commands: `dotnet test` for running tests +- TUnit requires .NET 8.0 or higher + +## Test Structure + +- No test class attributes required (like xUnit/NUnit) +- Use `[Test]` attribute for test methods (not `[Fact]` like xUnit) +- Follow the Arrange-Act-Assert (AAA) pattern +- Name tests using the pattern `MethodName_Scenario_ExpectedBehavior` +- Use lifecycle hooks: `[Before(Test)]` for setup and `[After(Test)]` for teardown +- Use `[Before(Class)]` and `[After(Class)]` for shared context between tests in a class +- Use `[Before(Assembly)]` and `[After(Assembly)]` for shared context across test classes +- TUnit supports advanced lifecycle hooks like `[Before(TestSession)]` and `[After(TestSession)]` + +## Standard Tests + +- Keep tests focused on a single behavior +- Avoid testing multiple behaviors in one test method +- Use TUnit's fluent assertion syntax with `await Assert.That()` +- Include only the assertions needed to verify the test case +- Make tests independent and idempotent (can run in any order) +- Avoid test interdependencies (use `[DependsOn]` attribute if needed) + +## Data-Driven Tests + +- Use `[Arguments]` attribute for inline test data (equivalent to xUnit's `[InlineData]`) +- Use `[MethodData]` for method-based test data (equivalent to xUnit's `[MemberData]`) +- Use `[ClassData]` for class-based test data +- Create custom data sources by implementing `ITestDataSource` +- Use meaningful parameter names in data-driven tests +- Multiple `[Arguments]` attributes can be applied to the same test method + +## Assertions + +- Use `await Assert.That(value).IsEqualTo(expected)` for value equality +- Use `await Assert.That(value).IsSameReferenceAs(expected)` for reference equality +- Use `await Assert.That(value).IsTrue()` or `await Assert.That(value).IsFalse()` for boolean conditions +- Use `await Assert.That(collection).Contains(item)` or `await Assert.That(collection).DoesNotContain(item)` for collections +- Use `await Assert.That(value).Matches(pattern)` for regex pattern matching +- Use `await Assert.That(action).Throws()` or `await Assert.That(asyncAction).ThrowsAsync()` to test exceptions +- Chain assertions with `.And` operator: `await Assert.That(value).IsNotNull().And.IsEqualTo(expected)` +- Use `.Or` operator for alternative conditions: `await Assert.That(value).IsEqualTo(1).Or.IsEqualTo(2)` +- Use `.Within(tolerance)` for DateTime and numeric comparisons with tolerance +- All assertions are asynchronous and must be awaited + +## Advanced Features + +- Use `[Repeat(n)]` to repeat tests multiple times +- Use `[Retry(n)]` for automatic retry on failure +- Use `[ParallelLimit]` to control parallel execution limits +- Use `[Skip("reason")]` to skip tests conditionally +- Use `[DependsOn(nameof(OtherTest))]` to create test dependencies +- Use `[Timeout(milliseconds)]` to set test timeouts +- Create custom attributes by extending TUnit's base attributes + +## Test Organization + +- Group tests by feature or component +- Use `[Category("CategoryName")]` for test categorization +- Use `[DisplayName("Custom Test Name")]` for custom test names +- Consider using `TestContext` for test diagnostics and information +- Use conditional attributes like custom `[WindowsOnly]` for platform-specific tests + +## Performance and Parallel Execution + +- TUnit runs tests in parallel by default (unlike xUnit which requires explicit configuration) +- Use `[NotInParallel]` to disable parallel execution for specific tests +- Use `[ParallelLimit]` with custom limit classes to control concurrency +- Tests within the same class run sequentially by default +- Use `[Repeat(n)]` with `[ParallelLimit]` for load testing scenarios + +## Migration from xUnit + +- Replace `[Fact]` with `[Test]` +- Replace `[Theory]` with `[Test]` and use `[Arguments]` for data +- Replace `[InlineData]` with `[Arguments]` +- Replace `[MemberData]` with `[MethodData]` +- Replace `Assert.Equal` with `await Assert.That(actual).IsEqualTo(expected)` +- Replace `Assert.True` with `await Assert.That(condition).IsTrue()` +- Replace `Assert.Throws` with `await Assert.That(action).Throws()` +- Replace constructor/IDisposable with `[Before(Test)]`/`[After(Test)]` +- Replace `IClassFixture` with `[Before(Class)]`/`[After(Class)]` + +**Why TUnit over xUnit?** + +TUnit offers a modern, fast, and flexible testing experience with advanced features not present in xUnit, such as asynchronous assertions, more refined lifecycle hooks, and improved data-driven testing capabilities. TUnit's fluent assertions provide clearer and more expressive test validation, making it especially suitable for complex .NET projects. diff --git a/.agents/skills/csharp-xunit/SKILL.md b/.agents/skills/csharp-xunit/SKILL.md new file mode 100644 index 0000000..4347c5a --- /dev/null +++ b/.agents/skills/csharp-xunit/SKILL.md @@ -0,0 +1,68 @@ +--- +name: csharp-xunit +description: 'Get best practices for XUnit unit testing, including data-driven tests' +--- + +# XUnit Best Practices + +Your goal is to help me write effective unit tests with XUnit, covering both standard and data-driven testing approaches. + +## Project Setup + +- Use a separate test project with naming convention `[ProjectName].Tests` +- Reference Microsoft.NET.Test.Sdk, xunit, and xunit.runner.visualstudio packages +- Create test classes that match the classes being tested (e.g., `CalculatorTests` for `Calculator`) +- Use .NET SDK test commands: `dotnet test` for running tests + +## Test Structure + +- No test class attributes required (unlike MSTest/NUnit) +- Use fact-based tests with `[Fact]` attribute for simple tests +- Follow the Arrange-Act-Assert (AAA) pattern +- Name tests using the pattern `MethodName_Scenario_ExpectedBehavior` +- Use constructor for setup and `IDisposable.Dispose()` for teardown +- Use `IClassFixture` for shared context between tests in a class +- Use `ICollectionFixture` for shared context between multiple test classes + +## Standard Tests + +- Keep tests focused on a single behavior +- Avoid testing multiple behaviors in one test method +- Use clear assertions that express intent +- Include only the assertions needed to verify the test case +- Make tests independent and idempotent (can run in any order) +- Avoid test interdependencies + +## Data-Driven Tests + +- Use `[Theory]` combined with data source attributes +- Use `[InlineData]` for inline test data +- Use `[MemberData]` for method-based test data +- Use `[ClassData]` for class-based test data +- Create custom data attributes by implementing `DataAttribute` +- Use meaningful parameter names in data-driven tests + +## Assertions + +- Use `Assert.Equal` for value equality +- Use `Assert.Same` for reference equality +- Use `Assert.True`/`Assert.False` for boolean conditions +- Use `Assert.Contains`/`Assert.DoesNotContain` for collections +- Use `Assert.Matches`/`Assert.DoesNotMatch` for regex pattern matching +- Use `Assert.Throws` or `await Assert.ThrowsAsync` to test exceptions +- Use fluent assertions library for more readable assertions + +## Mocking and Isolation + +- Consider using Moq or NSubstitute alongside XUnit +- Mock dependencies to isolate units under test +- Use interfaces to facilitate mocking +- Consider using a DI container for complex test setups + +## Test Organization + +- Group tests by feature or component +- Use `[Trait("Category", "CategoryName")]` for categorization +- Use collection fixtures to group tests with shared dependencies +- Consider output helpers (`ITestOutputHelper`) for test diagnostics +- Skip tests conditionally with `Skip = "reason"` in fact/theory attributes diff --git a/.agents/skills/dotnet-best-practices/SKILL.md b/.agents/skills/dotnet-best-practices/SKILL.md new file mode 100644 index 0000000..183d3be --- /dev/null +++ b/.agents/skills/dotnet-best-practices/SKILL.md @@ -0,0 +1,85 @@ +--- +name: dotnet-best-practices +description: 'Ensure .NET/C# code meets best practices for the solution/project.' +--- + +# .NET/C# Best Practices + +Your task is to ensure .NET/C# code in ${selection} meets the best practices specific to this solution/project. This includes: + +## Documentation & Structure + +- Create comprehensive XML documentation comments for all public classes, interfaces, methods, and properties +- Include parameter descriptions and return value descriptions in XML comments +- Follow the established namespace structure: {Core|Console|App|Service}.{Feature} + +## Design Patterns & Architecture + +- Use primary constructor syntax for dependency injection (e.g., `public class MyClass(IDependency dependency)`) +- Implement the Command Handler pattern with generic base classes (e.g., `CommandHandler`) +- Use interface segregation with clear naming conventions (prefix interfaces with 'I') +- Follow the Factory pattern for complex object creation. + +## Dependency Injection & Services + +- Use constructor dependency injection with null checks via ArgumentNullException +- Register services with appropriate lifetimes (Singleton, Scoped, Transient) +- Use Microsoft.Extensions.DependencyInjection patterns +- Implement service interfaces for testability + +## Resource Management & Localization + +- Use ResourceManager for localized messages and error strings +- Separate LogMessages and ErrorMessages resource files +- Access resources via `_resourceManager.GetString("MessageKey")` + +## Async/Await Patterns + +- Use async/await for all I/O operations and long-running tasks +- Return Task or Task from async methods +- Use ConfigureAwait(false) where appropriate +- Handle async exceptions properly + +## Testing Standards + +- Use MSTest framework with FluentAssertions for assertions +- Follow AAA pattern (Arrange, Act, Assert) +- Use Moq for mocking dependencies +- Test both success and failure scenarios +- Include null parameter validation tests + +## Configuration & Settings + +- Use strongly-typed configuration classes with data annotations +- Implement validation attributes (Required, NotEmptyOrWhitespace) +- Use IConfiguration binding for settings +- Support appsettings.json configuration files + +## Semantic Kernel & AI Integration + +- Use Microsoft.SemanticKernel for AI operations +- Implement proper kernel configuration and service registration +- Handle AI model settings (ChatCompletion, Embedding, etc.) +- Use structured output patterns for reliable AI responses + +## Error Handling & Logging + +- Use structured logging with Microsoft.Extensions.Logging +- Include scoped logging with meaningful context +- Throw specific exceptions with descriptive messages +- Use try-catch blocks for expected failure scenarios + +## Performance & Security + +- Use C# 12+ features and .NET 8 optimizations where applicable +- Implement proper input validation and sanitization +- Use parameterized queries for database operations +- Follow secure coding practices for AI/ML operations + +## Code Quality + +- Ensure SOLID principles compliance +- Avoid code duplication through base classes and utilities +- Use meaningful names that reflect domain concepts +- Keep methods focused and cohesive +- Implement proper disposal patterns for resources diff --git a/.agents/skills/dotnet-design-pattern-review/SKILL.md b/.agents/skills/dotnet-design-pattern-review/SKILL.md new file mode 100644 index 0000000..5d9ded0 --- /dev/null +++ b/.agents/skills/dotnet-design-pattern-review/SKILL.md @@ -0,0 +1,42 @@ +--- +name: dotnet-design-pattern-review +description: 'Review the C#/.NET code for design pattern implementation and suggest improvements.' +--- + +# .NET/C# Design Pattern Review + +Review the C#/.NET code in ${selection} for design pattern implementation and suggest improvements for the solution/project. Do not make any changes to the code, just provide a review. + +## Required Design Patterns + +- **Command Pattern**: Generic base classes (`CommandHandler`), `ICommandHandler` interface, `CommandHandlerOptions` inheritance, static `SetupCommand(IHost host)` methods +- **Factory Pattern**: Complex object creation service provider integration +- **Dependency Injection**: Primary constructor syntax, `ArgumentNullException` null checks, interface abstractions, proper service lifetimes +- **Repository Pattern**: Async data access interfaces provider abstractions for connections +- **Provider Pattern**: External service abstractions (database, AI), clear contracts, configuration handling +- **Resource Pattern**: ResourceManager for localized messages, separate .resx files (LogMessages, ErrorMessages) + +## Review Checklist + +- **Design Patterns**: Identify patterns used. Are Command Handler, Factory, Provider, and Repository patterns correctly implemented? Missing beneficial patterns? +- **Architecture**: Follow namespace conventions (`{Core|Console|App|Service}.{Feature}`)? Proper separation between Core/Console projects? Modular and readable? +- **.NET Best Practices**: Primary constructors, async/await with Task returns, ResourceManager usage, structured logging, strongly-typed configuration? +- **GoF Patterns**: Command, Factory, Template Method, Strategy patterns correctly implemented? +- **SOLID Principles**: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion violations? +- **Performance**: Proper async/await, resource disposal, ConfigureAwait(false), parallel processing opportunities? +- **Maintainability**: Clear separation of concerns, consistent error handling, proper configuration usage? +- **Testability**: Dependencies abstracted via interfaces, mockable components, async testability, AAA pattern compatibility? +- **Security**: Input validation, secure credential handling, parameterized queries, safe exception handling? +- **Documentation**: XML docs for public APIs, parameter/return descriptions, resource file organization? +- **Code Clarity**: Meaningful names reflecting domain concepts, clear intent through patterns, self-explanatory structure? +- **Clean Code**: Consistent style, appropriate method/class size, minimal complexity, eliminated duplication? + +## Improvement Focus Areas + +- **Command Handlers**: Validation in base class, consistent error handling, proper resource management +- **Factories**: Dependency configuration, service provider integration, disposal patterns +- **Providers**: Connection management, async patterns, exception handling and logging +- **Configuration**: Data annotations, validation attributes, secure sensitive value handling +- **AI/ML Integration**: Semantic Kernel patterns, structured output handling, model configuration + +Provide specific, actionable recommendations for improvements aligned with the project's architecture and .NET best practices. diff --git a/.agents/skills/dotnet-upgrade/SKILL.md b/.agents/skills/dotnet-upgrade/SKILL.md new file mode 100644 index 0000000..93ca760 --- /dev/null +++ b/.agents/skills/dotnet-upgrade/SKILL.md @@ -0,0 +1,116 @@ +--- +name: dotnet-upgrade +description: 'Ready-to-use prompts for comprehensive .NET framework upgrade analysis and execution' +--- + +# Project Discovery & Assessment + - name: "Project Classification Analysis" + prompt: "Identify all projects in the solution and classify them by type (`.NET Framework`, `.NET Core`, `.NET Standard`). Analyze each `.csproj` for its current `TargetFramework` and SDK usage." + + - name: "Dependency Compatibility Review" + prompt: "Review external and internal dependencies for framework compatibility. Determine the upgrade complexity based on dependency graph depth." + + - name: "Legacy Package Detection" + prompt: "Identify legacy `packages.config` projects needing migration to `PackageReference` format." + + # Upgrade Strategy & Sequencing + - name: "Project Upgrade Ordering" + prompt: "Recommend a project upgrade order from least to most dependent components. Suggest how to isolate class library upgrades before API or Azure Function migrations." + + - name: "Incremental Strategy Planning" + prompt: "Propose an incremental upgrade strategy with rollback checkpoints. Evaluate the use of **Upgrade Assistant** or **manual upgrades** based on project structure." + + - name: "Progress Tracking Setup" + prompt: "Generate an upgrade checklist for tracking build, test, and deployment readiness across all projects." + + # Framework Targeting & Code Adjustments + - name: "Target Framework Selection" + prompt: "Suggest the correct `TargetFramework` for each project (e.g., `net8.0`). Review and update deprecated SDK or build configurations." + + - name: "Code Modernization Analysis" + prompt: "Identify code patterns needing modernization (e.g., `WebHostBuilder` → `HostBuilder`). Suggest replacements for deprecated .NET APIs and third-party libraries." + + - name: "Async Pattern Conversion" + prompt: "Recommend conversion of synchronous calls to async where appropriate for improved performance and scalability." + + # NuGet & Dependency Management + - name: "Package Compatibility Analysis" + prompt: "Analyze outdated or incompatible NuGet packages and suggest compatible versions. Identify third-party libraries that lack .NET 8 support and provide migration paths." + + - name: "Shared Dependency Strategy" + prompt: "Recommend strategies for handling shared dependency upgrades across projects. Evaluate usage of legacy packages and suggest alternatives in Microsoft-supported namespaces." + + - name: "Transitive Dependency Review" + prompt: "Review transitive dependencies and potential version conflicts after upgrade. Suggest resolution strategies for dependency conflicts." + + # CI/CD & Build Pipeline Updates + - name: "Pipeline Configuration Analysis" + prompt: "Analyze YAML build definitions for SDK version pinning and recommend updates. Suggest modifications for `UseDotNet@2` and `NuGetToolInstaller` tasks." + + - name: "Build Pipeline Modernization" + prompt: "Generate updated build pipeline snippets for .NET 8 migration. Recommend validation builds on feature branches before merging to main." + + - name: "CI Automation Enhancement" + prompt: "Identify opportunities to automate test and build verification in CI pipelines. Suggest strategies for continuous integration validation." + + # Testing & Validation + - name: "Build Validation Strategy" + prompt: "Propose validation checks to ensure the upgraded solution builds and runs successfully. Recommend automated test execution for unit and integration suites post-upgrade." + + - name: "Service Integration Verification" + prompt: "Generate validation steps to verify logging, telemetry, and service connectivity. Suggest strategies for verifying backward compatibility and runtime behavior." + + - name: "Deployment Readiness Check" + prompt: "Recommend UAT deployment verification steps before production rollout. Create comprehensive testing scenarios for upgraded components." + + # Breaking Change Analysis + - name: "API Deprecation Detection" + prompt: "Identify deprecated APIs or removed namespaces between target versions. Suggest automated scanning using `.NET Upgrade Assistant` and API Analyzer." + + - name: "API Replacement Strategy" + prompt: "Recommend replacement APIs or libraries for known breaking areas. Review configuration changes such as `Startup.cs` → `Program.cs` refactoring." + + - name: "Regression Testing Focus" + prompt: "Suggest regression testing scenarios focused on upgraded API endpoints or services. Create test plans for critical functionality validation." + + # Version Control & Commit Strategy + - name: "Branching Strategy Planning" + prompt: "Recommend branching strategy for safe upgrade with rollback capability. Generate commit templates for partial and complete project upgrades." + + - name: "PR Structure Optimization" + prompt: "Suggest best practices for creating structured PRs (`Upgrade to .NET [Version]`). Identify tagging strategies for PRs involving breaking changes." + + - name: "Code Review Guidelines" + prompt: "Recommend peer review focus areas (build, test, and dependency validation). Create checklists for effective upgrade reviews." + + # Documentation & Communication + - name: "Upgrade Documentation Strategy" + prompt: "Suggest how to document each project's framework change in the PR. Propose automated release note generation summarizing upgrades and test results." + + - name: "Stakeholder Communication" + prompt: "Recommend communicating version upgrades and migration timelines to consumers. Generate documentation templates for dependency updates and validation results." + + - name: "Progress Tracking Systems" + prompt: "Suggest maintaining an upgrade summary dashboard or markdown checklist. Create templates for tracking upgrade progress across multiple projects." + + # Tools & Automation + - name: "Upgrade Tool Selection" + prompt: "Recommend when and how to use: `.NET Upgrade Assistant`, `dotnet list package --outdated`, `dotnet migrate`, and `graph.json` dependency visualization." + + - name: "Analysis Script Generation" + prompt: "Generate scripts or prompts for analyzing dependency graphs before upgrading. Propose AI-assisted prompts for Copilot to identify upgrade issues automatically." + + - name: "Multi-Repository Validation" + prompt: "Suggest how to validate automation output across multiple repositories. Create standardized validation workflows for enterprise-scale upgrades." + + # Final Validation & Delivery + - name: "Final Solution Validation" + prompt: "Generate validation steps to confirm the final upgraded solution passes all validation checks. Suggest production deployment verification steps post-upgrade." + + - name: "Deployment Readiness Confirmation" + prompt: "Recommend generating final test results and build artifacts. Create a checklist summarizing completion across projects (builds/tests/deployment)." + + - name: "Release Documentation" + prompt: "Generate a release note summarizing framework changes and CI/CD updates. Create comprehensive upgrade summary documentation." + +--- diff --git a/Application/Abstractions/Infrastructure/IEventBus.cs b/Application/Abstractions/Infrastructure/IEventBus.cs index 578a1b2..18925d0 100644 --- a/Application/Abstractions/Infrastructure/IEventBus.cs +++ b/Application/Abstractions/Infrastructure/IEventBus.cs @@ -2,6 +2,6 @@ { public interface IEventBus { - Task PublishAsync(T message, CancellationToken cancellationToken); + Task PublishAsync(T message, CancellationToken cancellationToken) where T : class; } } \ No newline at end of file diff --git a/Application/Abstractions/Infrastructure/IStorageService.cs b/Application/Abstractions/Infrastructure/IStorageService.cs index a40b237..26af204 100644 --- a/Application/Abstractions/Infrastructure/IStorageService.cs +++ b/Application/Abstractions/Infrastructure/IStorageService.cs @@ -6,6 +6,8 @@ Task GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken); + Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken); + Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); diff --git a/Application/Configuration/PlayListConfiguration.cs b/Application/Configuration/PlayListConfiguration.cs index dd1964d..5bd7a37 100644 --- a/Application/Configuration/PlayListConfiguration.cs +++ b/Application/Configuration/PlayListConfiguration.cs @@ -15,6 +15,9 @@ namespace Musify.Application.Configuration public class PlayListRoutes { + [Required] + public string UploadsFolder { get; set; } = "uploads"; + [Required] public string ParentFolders { get; set; } = "PlayLists"; @@ -42,27 +45,36 @@ namespace Musify.Application.Configuration [Required] public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; - public string SmallPicturesPath => Path.Combine(ParentFolders, SmallPicturesFolder); + public string SmallPicturesPath => CombineKey(ParentFolders, SmallPicturesFolder); - public string MediumPicturesPath => Path.Combine(ParentFolders, MediumPicturesFolder); + public string MediumPicturesPath => CombineKey(ParentFolders, MediumPicturesFolder); - public string LargePicturesPath => Path.Combine(ParentFolders, LargePicturesFolder); + public string LargePicturesPath => CombineKey(ParentFolders, LargePicturesFolder); - public string PresetSmallPicturePath => Path.Combine(ParentFolders, PresetSmallPicture); + public string PresetSmallPicturePath => CombineKey(ParentFolders, PresetSmallPicture); - public string PresetMediumPicturePath => Path.Combine(ParentFolders, PresetMediumPicture); + public string PresetMediumPicturePath => CombineKey(ParentFolders, PresetMediumPicture); - public string PresetLargePicturePath => Path.Combine(ParentFolders, PresetLargePicture); + public string PresetLargePicturePath => CombineKey(ParentFolders, PresetLargePicture); - public string OriginalPicturesPath => Path.Combine(ParentFolders, OriginalPicturesFolder); + public string OriginalPicturesPath => CombineKey(ParentFolders, OriginalPicturesFolder); - public string BuildOriginalPicturePath(string pictureName) => Path.Combine(ParentFolders, OriginalPicturesFolder, pictureName); + public string BuildOriginalPicturePath(string pictureName) => CombineKey(ParentFolders, OriginalPicturesFolder, pictureName); - public string BuildSmallPicturePath(string pictureName) => Path.Combine(ParentFolders, SmallPicturesFolder, pictureName); + public string BuildOriginalPicturePath(Guid userId, string pictureName) => CombineKey(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName); - public string BuildMediumPicturePath(string pictureName) => Path.Combine(ParentFolders, MediumPicturesFolder, pictureName); + public string BuildSmallPicturePath(string pictureName) => CombineKey(ParentFolders, SmallPicturesFolder, pictureName); - public string BuildLargePicturePath(string pictureName) => Path.Combine(ParentFolders, LargePicturesFolder, pictureName); + public string BuildMediumPicturePath(string pictureName) => CombineKey(ParentFolders, MediumPicturesFolder, pictureName); + + public string BuildLargePicturePath(string pictureName) => CombineKey(ParentFolders, LargePicturesFolder, pictureName); + + static string CombineKey(params string[] segments) + { + return string.Join('/', segments + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); + } } public class PlayListPicturesSizes diff --git a/Application/Configuration/TrackConfiguration.cs b/Application/Configuration/TrackConfiguration.cs index c9658ef..f7b5e76 100644 --- a/Application/Configuration/TrackConfiguration.cs +++ b/Application/Configuration/TrackConfiguration.cs @@ -15,6 +15,9 @@ namespace Musify.Application.Configuration public class TrackRoutes { + [Required] + public string UploadsFolder { get; set; } = "uploads"; + [Required] public string ParentFolder { get; set; } = "Tracks"; @@ -48,35 +51,46 @@ namespace Musify.Application.Configuration [Required] public string ProcessedAudioFolder { get; set; } = "ProcessedAudios"; - public string SmallPicturesPath => Path.Combine(ParentFolder, SmallPicturesFolder); + public string SmallPicturesPath => CombineKey(ParentFolder, SmallPicturesFolder); - public string MediumPicturesPath => Path.Combine(ParentFolder, MediumPicturesFolder); + public string MediumPicturesPath => CombineKey(ParentFolder, MediumPicturesFolder); - public string LargePicturesPath => Path.Combine(ParentFolder, LargePicturesFolder); + public string LargePicturesPath => CombineKey(ParentFolder, LargePicturesFolder); - public string PresetSmallPicturePath => Path.Combine(ParentFolder, PresetSmallPicture); + public string PresetSmallPicturePath => CombineKey(ParentFolder, PresetSmallPicture); - public string PresetMediumPicturePath => Path.Combine(ParentFolder, PresetMediumPicture); + public string PresetMediumPicturePath => CombineKey(ParentFolder, PresetMediumPicture); - public string PresetLargePicturePath => Path.Combine(ParentFolder, PresetLargePicture); + public string PresetLargePicturePath => CombineKey(ParentFolder, PresetLargePicture); - public string OriginalPicturesPath => Path.Combine(ParentFolder, OriginalPicturesFolder); + public string OriginalPicturesPath => CombineKey(ParentFolder, OriginalPicturesFolder); - public string OriginalAudiosPath => Path.Combine(ParentFolder, OriginalAudiosFolder); + public string OriginalAudiosPath => CombineKey(ParentFolder, OriginalAudiosFolder); - public string ProcessedAudiosPath => Path.Combine(ParentFolder, ProcessedAudioFolder); + public string ProcessedAudiosPath => CombineKey(ParentFolder, ProcessedAudioFolder); - public string BuildOriginalPicturePath(string pictureName) => Path.Combine(OriginalPicturesPath, pictureName); + public string BuildOriginalPicturePath(string pictureName) => CombineKey(OriginalPicturesPath, pictureName); - public string BuildSmallPicturePath(string pictureName) => Path.Combine(SmallPicturesPath, pictureName); + public string BuildOriginalPicturePath(Guid userId, string pictureName) => CombineKey(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName); - public string BuildMediumPicturePath(string pictureName) => Path.Combine(MediumPicturesPath, pictureName); + public string BuildSmallPicturePath(string pictureName) => CombineKey(SmallPicturesPath, pictureName); - public string BuildLargePicturePath(string pictureName) => Path.Combine(LargePicturesPath, pictureName); + public string BuildMediumPicturePath(string pictureName) => CombineKey(MediumPicturesPath, pictureName); - public string BuildOriginalAudioPath(string audioName) => Path.Combine(OriginalAudiosPath, audioName); + public string BuildLargePicturePath(string pictureName) => CombineKey(LargePicturesPath, pictureName); - public string BuildProcessedAudioPath(string audioName) => Path.Combine(ProcessedAudiosPath, audioName); + public string BuildOriginalAudioPath(string audioName) => CombineKey(OriginalAudiosPath, audioName); + + public string BuildOriginalAudioPath(Guid userId, string audioName) => CombineKey(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName); + + public string BuildProcessedAudioPath(string audioName) => CombineKey(ProcessedAudiosPath, audioName); + + static string CombineKey(params string[] segments) + { + return string.Join('/', segments + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); + } } public class TrackPicturesSizes diff --git a/Application/PlayLists/Commands/CreatePlayListCommand.cs b/Application/PlayLists/Commands/CreatePlayListCommand.cs index 6da1706..29ee783 100644 --- a/Application/PlayLists/Commands/CreatePlayListCommand.cs +++ b/Application/PlayLists/Commands/CreatePlayListCommand.cs @@ -1,8 +1,6 @@ using Ardalis.Result; using Mediator; -using Musify.Application.Abstractions.Application; using Musify.Application.PlayLists.Responses; -using Musify.Domain.Entities; namespace Musify.Application.PlayLists.Commands { @@ -10,22 +8,6 @@ namespace Musify.Application.PlayLists.Commands Guid UserId, string Name, string Description, - IFileData? Picture) - : ICommand> - { - public static PlayList ToEntity(CreatePlayListCommand command, string originalKey, string smallKey, string mediumKey, string largeKey) - { - return new PlayList - { - UserId = command.UserId, - Name = command.Name, - NormalizedName = command.Name.Trim().ToUpperInvariant(), - Description = command.Description, - OriginalPictureName = originalKey, - SmallPictureName = smallKey, - MediumPictureName = mediumKey, - LargePictureName = largeKey, - }; - } - } + string? OriginalPictureName) + : ICommand>; } \ No newline at end of file diff --git a/Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs b/Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs new file mode 100644 index 0000000..33e01f6 --- /dev/null +++ b/Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs @@ -0,0 +1,12 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.PlayLists.Responses; + +namespace Musify.Application.PlayLists.Commands +{ + public record RequestPlayListPictureUploadCommand( + Guid UserId, + string FileType, + string ContentType) + : ICommand>; +} diff --git a/Application/PlayLists/Commands/UpdatePlayListCommand.cs b/Application/PlayLists/Commands/UpdatePlayListCommand.cs index 748b8a9..5671782 100644 --- a/Application/PlayLists/Commands/UpdatePlayListCommand.cs +++ b/Application/PlayLists/Commands/UpdatePlayListCommand.cs @@ -1,6 +1,5 @@ using Ardalis.Result; using Mediator; -using Musify.Application.Abstractions.Application; using Musify.Application.PlayLists.Responses; namespace Musify.Application.PlayLists.Commands @@ -10,6 +9,6 @@ namespace Musify.Application.PlayLists.Commands Guid PlayListId, string? NewName, string? NewDescription, - IFileData? NewPicture) + string? NewOriginalPictureName) : ICommand>; } \ No newline at end of file diff --git a/Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs b/Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs index 76fa134..5fb98a0 100644 --- a/Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs +++ b/Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs @@ -3,7 +3,6 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Application.Abstractions.Application; using Musify.Application.Abstractions.Infrastructure; using Musify.Application.Events; using Musify.Application.PlayLists.Commands; @@ -15,7 +14,6 @@ namespace Musify.Application.PlayLists.Handlers public class CreatePlayListCommandHandler( IEventBus eventBus, IDatabase database, - IStorageService storageService, ILogger logger, ApplicationStorageConfiguration storageConfiguration, PlayListConfiguration playListConfiguration) @@ -32,79 +30,43 @@ namespace Musify.Application.PlayLists.Handlers return Result.NotFound($"User {request.UserId} not found"); } - var playList = CreatePlayListCommand.ToEntity( - request, - playListConfiguration.Routes.PresetOriginalPicture, - playListConfiguration.Routes.PresetSmallPicture, - playListConfiguration.Routes.PresetMediumPicture, - playListConfiguration.Routes.PresetLargePicture); + var originalPictureName = request.OriginalPictureName ?? playListConfiguration.Routes.PresetOriginalPicture; - if (request.Picture is not null) + var playList = new PlayList { - var uploadResult = await UploadPictureAsync(playList, request.Picture, cancellationToken); - if (!uploadResult.IsSuccess) - return uploadResult; + UserId = request.UserId, + Name = request.Name, + NormalizedName = request.Name.Trim().ToUpperInvariant(), + Description = request.Description, + OriginalPictureName = originalPictureName, + SmallPictureName = playListConfiguration.Routes.PresetSmallPicture, + MediumPictureName = playListConfiguration.Routes.PresetMediumPicture, + LargePictureName = playListConfiguration.Routes.PresetLargePicture + }; + + await database.PlayLists.AddAsync(playList, cancellationToken); + + if (request.OriginalPictureName is not null) + { + var publishResult = await PublishPictureUpdateEventAsync(playList, cancellationToken); + if (!publishResult.IsSuccess) + return publishResult; } try { - await database.PlayLists.AddAsync(playList, cancellationToken); await database.SaveChangesAsync(cancellationToken); } catch (Exception exception) { logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId); - await RollbackAllPicturesAsync(playList, cancellationToken); return Result.Error($"Failed to create playlist for user {request.UserId}"); } - if (request.Picture is not null) - { - var publishResult = await PublishPictureUpdateEventAsync(playList, cancellationToken); - if (!publishResult.IsSuccess) - { - await TryRollbackOnPublishFailureAsync(playList, cancellationToken); - return publishResult; - } - } - logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId); return Result.Created(PlayListApplicationResponse.FromEntity(playList)); } - async Task TryRollbackOnPublishFailureAsync(PlayList playList, CancellationToken cancellationToken) - { - try - { - database.PlayLists.Remove(playList); - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to rollback playlist creation after publish failure for playlist {PlayListId}", playList.Id); - } - - await RollbackAllPicturesAsync(playList, cancellationToken); - } - - async Task UploadPictureAsync(PlayList playList, IFileData picture, CancellationToken cancellationToken) - { - var pictureName = Guid.NewGuid() + picture.FileType; - var pictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(pictureName); - - try - { - await storageService.UploadFileAsync(picture.FileStream, picture.ContentType, storageConfiguration.Bucket, pictureKey, cancellationToken); - playList.OriginalPictureName = pictureName; - return Result.Success(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to upload picture for playlist {PlayListId}", playList.Id); - return Result.Error($"Failed to upload picture for playlist {playList.Id}"); - } - } - async Task PublishPictureUpdateEventAsync(PlayList playList, CancellationToken cancellationToken) { try @@ -112,7 +74,7 @@ namespace Musify.Application.PlayLists.Handlers var updateEvent = new UpdatePlayListPictureEvent( playList.Id, storageConfiguration.Bucket, - playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName), + playListConfiguration.Routes.BuildOriginalPicturePath(playList.UserId, playList.OriginalPictureName), new ImageSize( playListConfiguration.Routes.SmallPicturesPath, playListConfiguration.PicturesSizes.SmallPictureWidth, @@ -135,25 +97,5 @@ namespace Musify.Application.PlayLists.Handlers return Result.Error($"Failed to publish picture update event for playlist {playList.Id}"); } } - - async Task RollbackAllPicturesAsync(PlayList playList, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(playList.OriginalPictureName)) - return; - - if (playList.OriginalPictureName == playListConfiguration.Routes.PresetOriginalPicture) - return; - - var originalPicturePath = playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName); - - try - { - await storageService.RemoveFileAsync(storageConfiguration.Bucket, originalPicturePath, cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to rollback picture upload for playlist {PlayListId}", playList.Id); - } - } } } \ No newline at end of file diff --git a/Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs b/Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs index 76709cb..08d3774 100644 --- a/Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs +++ b/Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs @@ -33,23 +33,12 @@ namespace Musify.Application.PlayLists.Handlers var mediumPictureName = playList.MediumPictureName; var largePictureName = playList.LargePictureName; - var originalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName); + var originalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(playList.UserId, playList.OriginalPictureName); var smallPictureKey = playListConfiguration.Routes.BuildSmallPicturePath(playList.SmallPictureName); var mediumPictureKey = playListConfiguration.Routes.BuildMediumPicturePath(playList.MediumPictureName); var largePictureKey = playListConfiguration.Routes.BuildLargePicturePath(playList.LargePictureName); - try - { - database.PlayLists.Remove(playList); - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to delete playlist {PlayListId}", request.PlayListId); - return Result.Error($"Failed to delete playlist {request.PlayListId}"); - } - - await PublishRemoveFileEventsBestEffortAsync( + var publishResult = await PublishRemoveFileEventsAsync( playList.Id, originalPictureName, smallPictureName, @@ -62,10 +51,24 @@ namespace Musify.Application.PlayLists.Handlers playListConfiguration.Routes, cancellationToken); + if (!publishResult.IsSuccess) + return publishResult; + + try + { + database.PlayLists.Remove(playList); + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to delete playlist {PlayListId}", request.PlayListId); + return Result.Error($"Failed to delete playlist {request.PlayListId}"); + } + return Result.NoContent(); } - async Task PublishRemoveFileEventsBestEffortAsync( + async Task PublishRemoveFileEventsAsync( Guid playListId, string originalPictureName, string smallPictureName, @@ -79,20 +82,31 @@ namespace Musify.Application.PlayLists.Handlers CancellationToken cancellationToken) { if (originalPictureName != playListRoutes.PresetOriginalPicture) - await PublishRemoveFileEventIgnoringErrorsAsync(playListId, originalPictureKey, cancellationToken); + { + var result = await PublishRemoveFileEventAsync(playListId, originalPictureKey, cancellationToken); + if (!result.IsSuccess) + return result; + } if (smallPictureName != playListRoutes.PresetSmallPicture) - await PublishRemoveFileEventIgnoringErrorsAsync(playListId, smallPictureKey, cancellationToken); + { + var result = await PublishRemoveFileEventAsync(playListId, smallPictureKey, cancellationToken); + if (!result.IsSuccess) + return result; + } if (mediumPictureName != playListRoutes.PresetMediumPicture) - await PublishRemoveFileEventIgnoringErrorsAsync(playListId, mediumPictureKey, cancellationToken); + { + var result = await PublishRemoveFileEventAsync(playListId, mediumPictureKey, cancellationToken); + if (!result.IsSuccess) + return result; + } if (largePictureName != playListRoutes.PresetLargePicture) - await PublishRemoveFileEventIgnoringErrorsAsync(playListId, largePictureKey, cancellationToken); - } + { + var result = await PublishRemoveFileEventAsync(playListId, largePictureKey, cancellationToken); + if (!result.IsSuccess) + return result; + } - async Task PublishRemoveFileEventIgnoringErrorsAsync(Guid playListId, string key, CancellationToken cancellationToken) - { - var result = await PublishRemoveFileEventAsync(playListId, key, cancellationToken); - if (!result.IsSuccess) - logger.LogWarning("Remove file event publish failed for playlist {PlayListId} with key {Key}", playListId, key); + return Result.Success(); } async Task PublishRemoveFileEventAsync(Guid playListId, string key, CancellationToken cancellationToken) diff --git a/Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs b/Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs new file mode 100644 index 0000000..3f2c589 --- /dev/null +++ b/Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs @@ -0,0 +1,88 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Abstractions.Infrastructure; +using Musify.Application.Configuration; +using Musify.Application.PlayLists.Commands; +using Musify.Application.PlayLists.Responses; + +namespace Musify.Application.PlayLists.Handlers +{ + public class RequestPlayListPictureUploadCommandHandler( + IDatabase database, + IStorageService storageService, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + PlayListConfiguration playListConfiguration) + : ICommandHandler> + { + static readonly string[] AllowedExtensions = [".webp", ".png", ".jpg", ".jpeg"]; + const int ExpiresInSeconds = 600; + + public async ValueTask> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) + { + logger.LogWarning("User {UserId} not found", request.UserId); + return Result.NotFound($"User {request.UserId} not found"); + } + + var extensionResult = ValidateExtension(request.FileType); + if (!extensionResult.IsSuccess) + return Result.Invalid(extensionResult.Errors.Select(error => new ValidationError(error)).ToArray()); + + if (string.IsNullOrWhiteSpace(request.ContentType)) + return Result.Invalid(new ValidationError("ContentType is required")); + + var pictureName = Guid.NewGuid() + extensionResult.Value; + var key = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureName); + + try + { + var uploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + key, + request.ContentType, + TimeSpan.FromSeconds(ExpiresInSeconds), + cancellationToken); + + return Result.Success(new PlayListPictureUploadResponse( + storageConfiguration.Bucket, + key, + pictureName, + request.ContentType, + ExpiresInSeconds, + uploadUrl)); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId); + return Result.Error("Failed to generate upload URL"); + } + } + + static Result ValidateExtension(string fileType) + { + if (string.IsNullOrWhiteSpace(fileType)) + return Result.Error("FileType is required"); + + var extension = fileType.Trim(); + if (!extension.StartsWith('.')) + extension = "." + extension; + + extension = extension.ToLowerInvariant(); + + if (extension.Contains('/') || extension.Contains('\\')) + return Result.Error("Invalid FileType"); + + if (!AllowedExtensions.Contains(extension)) + return Result.Error($"Unsupported FileType '{extension}'"); + + return Result.Success(extension); + } + } +} diff --git a/Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs b/Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs index 0ec4b4b..05a4501 100644 --- a/Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs +++ b/Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs @@ -3,23 +3,21 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Application.Abstractions.Application; using Musify.Application.Abstractions.Infrastructure; using Musify.Application.Events; using Musify.Application.PlayLists.Commands; using Musify.Application.PlayLists.Responses; -using Musify.Domain.Entities; namespace Musify.Application.PlayLists.Handlers { - public class UpdatePlayListCommandHandler(IEventBus eventBus, IDatabase database, IStorageService storageHandler, - ILogger logger, ApplicationStorageConfiguration storageConfiguration, + public class UpdatePlayListCommandHandler( + IEventBus eventBus, + IDatabase database, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, PlayListConfiguration playListConfiguration) : ICommandHandler> { - record UpdatePictureOutcome(Result Result, bool PictureUpdated, string? UploadedPictureKey); - record PreviousPlayListState(string Name, string NormalizedName, string Description, string OriginalPictureName); - public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) { var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); @@ -35,14 +33,25 @@ namespace Musify.Application.PlayLists.Handlers return Result.Unauthorized(); } - var previousState = CapturePreviousState(playListEntity); + if (request.NewName is not null) + { + playListEntity.Name = request.NewName; + playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant(); + } - UpdateName(playListEntity, request.NewName); - UpdateDescription(playListEntity, request.NewDescription); + if (request.NewDescription is not null) + { + playListEntity.Description = request.NewDescription; + } - var pictureOutcome = await UpdatePicture(playListEntity, request.NewPicture, cancellationToken); - if (!pictureOutcome.Result.IsSuccess) - return pictureOutcome.Result; + if (request.NewOriginalPictureName is not null) + { + playListEntity.OriginalPictureName = request.NewOriginalPictureName; + + var publishResult = await PublishPlayListPictureEvent(playListEntity.UserId, playListEntity.Id, playListEntity.OriginalPictureName, cancellationToken); + if (!publishResult.IsSuccess) + return publishResult; + } try { @@ -51,86 +60,22 @@ namespace Musify.Application.PlayLists.Handlers } catch (Exception exception) { - if (pictureOutcome.PictureUpdated && pictureOutcome.UploadedPictureKey is not null) - await Rollback(pictureOutcome.UploadedPictureKey, cancellationToken); logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id); return Result.Error($"Failed to update playlist {playListEntity.Id}"); } - if (pictureOutcome.PictureUpdated) - { - var publishResult = await PublishPlayListPictureEvent(playListEntity, cancellationToken); - if (!publishResult.IsSuccess) - { - await TryRollbackAfterPublishFailureAsync( - playListEntity, - previousState, - pictureOutcome.UploadedPictureKey, - cancellationToken); - return Result.Error($"Failed to update playlist {playListEntity.Id}"); - } - } - logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id); return Result.Success(PlayListApplicationResponse.FromEntity(playListEntity)); } - void UpdateName(PlayList playList, string? newName) - { - if (newName is null) - return; - - playList.Name = newName; - playList.NormalizedName = newName.Trim().ToUpperInvariant(); - } - - void UpdateDescription(PlayList playList, string? newDescription) - { - if (newDescription is null) - return; - - playList.Description = newDescription; - } - - static PreviousPlayListState CapturePreviousState(PlayList playList) - => new(playList.Name, playList.NormalizedName, playList.Description, playList.OriginalPictureName); - - async Task UpdatePicture( - PlayList playList, - IFileData? newPicture, - CancellationToken cancellationToken) - { - if (newPicture is null) - return new UpdatePictureOutcome(Result.Success(), false, null); - - var pictureName = Guid.NewGuid() + newPicture.FileType; - var pictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(pictureName); - try - { - await storageHandler.UploadFileAsync( - newPicture.FileStream, - newPicture.ContentType, - storageConfiguration.Bucket, - pictureKey, - cancellationToken); - } - catch - { - return new UpdatePictureOutcome(Result.Error("Failed to upload picture to storage"), false, null); - } - - playList.OriginalPictureName = pictureName; - return new UpdatePictureOutcome(Result.Success(), true, pictureKey); - } - - async Task PublishPlayListPictureEvent(PlayList playList, CancellationToken cancellationToken) + async Task PublishPlayListPictureEvent(Guid userId, Guid playListId, string originalPictureName, CancellationToken cancellationToken) { try { await eventBus.PublishAsync(new UpdatePlayListPictureEvent( - playList.Id, + playListId, storageConfiguration.Bucket, - playListConfiguration.Routes.BuildOriginalPicturePath(playList.OriginalPictureName), + playListConfiguration.Routes.BuildOriginalPicturePath(userId, originalPictureName), new ImageSize( playListConfiguration.Routes.SmallPicturesPath, playListConfiguration.PicturesSizes.SmallPictureWidth, @@ -148,50 +93,8 @@ namespace Musify.Application.PlayLists.Handlers } catch (Exception exception) { - logger.LogError(exception, "Failed to publish playlist picture update event for playlist {PlayListId}", playList.Id); - return Result.Error($"Failed to publish playlist {playList.Id} picture update event"); - } - } - - async Task Rollback(string pictureKey, CancellationToken cancellationToken) - { - await storageHandler.RemoveFileAsync( - storageConfiguration.Bucket, - pictureKey, - cancellationToken); - logger.LogInformation("Rolled back uploaded picture {PictureKey} from storage", pictureKey); - } - - async Task TryRollbackAfterPublishFailureAsync( - PlayList playList, - PreviousPlayListState previousState, - string? uploadedPictureKey, - CancellationToken cancellationToken) - { - playList.Name = previousState.Name; - playList.NormalizedName = previousState.NormalizedName; - playList.Description = previousState.Description; - playList.OriginalPictureName = previousState.OriginalPictureName; - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to rollback playlist state after publish failure for playlist {PlayListId}", playList.Id); - } - - if (uploadedPictureKey is null) - return; - - try - { - await Rollback(uploadedPictureKey, cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to rollback uploaded picture after publish failure for playlist {PlayListId}", playList.Id); + logger.LogError(exception, "Failed to publish playlist picture update event for playlist {PlayListId}", playListId); + return Result.Error($"Failed to publish playlist {playListId} picture update event"); } } } diff --git a/Application/PlayLists/Responses/PlayListPictureUploadResponse.cs b/Application/PlayLists/Responses/PlayListPictureUploadResponse.cs new file mode 100644 index 0000000..1a8ba92 --- /dev/null +++ b/Application/PlayLists/Responses/PlayListPictureUploadResponse.cs @@ -0,0 +1,10 @@ +namespace Musify.Application.PlayLists.Responses +{ + public record PlayListPictureUploadResponse( + string Bucket, + string Key, + string PictureName, + string ContentType, + int ExpiresInSeconds, + string UploadUrl); +} diff --git a/Application/Tracks/Commands/CreateTrackCommand.cs b/Application/Tracks/Commands/CreateTrackCommand.cs index f99e324..652015a 100644 --- a/Application/Tracks/Commands/CreateTrackCommand.cs +++ b/Application/Tracks/Commands/CreateTrackCommand.cs @@ -1,30 +1,13 @@ using Ardalis.Result; using Mediator; -using Musify.Application.Abstractions.Application; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; namespace Musify.Application.Tracks.Commands { public record CreateTrackCommand( Guid UserId, string Title, - IFileData Picture, - IFileData Audio) - : ICommand > - { - public static Track ToEntity(CreateTrackCommand command, string smallPictureName, string mediumPictureName, string largePictureName) - { - return new Track - { - Title = command.Title, - NormalizedTitle = command.Title.ToUpperInvariant(), - OriginalPictureName = Guid.NewGuid() + command.Picture.FileType, - OriginalAudioName = Guid.NewGuid() + command.Audio.FileType, - SmallPictureName = smallPictureName, - MediumPictureName = mediumPictureName, - LargePictureName = largePictureName - }; - } - } + string OriginalPictureName, + string OriginalAudioName) + : ICommand>; } \ No newline at end of file diff --git a/Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs b/Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs new file mode 100644 index 0000000..022a0e1 --- /dev/null +++ b/Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs @@ -0,0 +1,14 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks.Commands +{ + public record RequestTrackUploadUrlsCommand( + Guid UserId, + string PictureFileType, + string PictureContentType, + string AudioFileType, + string AudioContentType) + : ICommand>; +} diff --git a/Application/Tracks/Handler/CreateTrackCommandHandler.cs b/Application/Tracks/Handler/CreateTrackCommandHandler.cs index 07625a3..c014064 100644 --- a/Application/Tracks/Handler/CreateTrackCommandHandler.cs +++ b/Application/Tracks/Handler/CreateTrackCommandHandler.cs @@ -11,9 +11,13 @@ using Musify.Domain.Entities; namespace Musify.Application.Tracks.Handler { - public class CreateTrackCommandHandler(IDatabase database, IEventBus eventBus, IStorageService storageHandler, - ILogger logger, ApplicationStorageConfiguration storageConfiguration, TrackConfiguration trackConfiguration) - : ICommandHandler > + public class CreateTrackCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration) + : ICommandHandler> { public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) { @@ -25,102 +29,50 @@ namespace Musify.Application.Tracks.Handler return Result.NotFound($"User {request.UserId} not found"); } - var trackEntity = CreateTrackCommand.ToEntity( - request, - trackConfiguration.Routes.PresetSmallPicture, - trackConfiguration.Routes.PresetMediumPicture, - trackConfiguration.Routes.PresetLargePicture); + var trackEntity = new Track + { + Title = request.Title, + NormalizedTitle = request.Title.ToUpperInvariant(), + OriginalPictureName = request.OriginalPictureName, + OriginalAudioName = request.OriginalAudioName, + SmallPictureName = trackConfiguration.Routes.PresetSmallPicture, + MediumPictureName = trackConfiguration.Routes.PresetMediumPicture, + LargePictureName = trackConfiguration.Routes.PresetLargePicture, + PicturesProcessingStatus = ProcessingStatus.Pending, + AudioTranscodeProcessingStatus = ProcessingStatus.Pending + }; - var fullPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(trackEntity.OriginalPictureName); - var fullAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(trackEntity.OriginalAudioName); + await database.Tracks.AddAsync(trackEntity, cancellationToken); - var uploadResult = await UploadFilesAsync(request, fullPictureKey, fullAudioKey, cancellationToken); - if (!uploadResult.IsSuccess) - return uploadResult; + var publishPictureResult = await PublishUpdateEvent(request.UserId, trackEntity, cancellationToken); + if (!publishPictureResult.IsSuccess) + return Result.Error($"Failed to create track for {request.Title}"); + + var publishTranscodeResult = await PublishTranscodeEvent(request.UserId, trackEntity, cancellationToken); + if (!publishTranscodeResult.IsSuccess) + return Result.Error($"Failed to create track for {request.Title}"); try { - await database.Tracks.AddAsync(trackEntity, cancellationToken); await database.SaveChangesAsync(cancellationToken); } catch (Exception exception) { - logger.LogError(exception, "Failed to save track {Title} after uploading files", request.Title); - await RollbackFilesAsync(fullPictureKey, fullAudioKey, cancellationToken); - return Result.Error($"Failed to create track for {request.Title}"); - } - - var publishPictureResult = await PublishUpdateEvent(trackEntity, cancellationToken); - if (!publishPictureResult.IsSuccess) - { - await RollbackTrackAfterPublishFailureAsync(trackEntity, fullPictureKey, fullAudioKey, cancellationToken); - return Result.Error($"Failed to create track for {request.Title}"); - } - - var publishTranscodeResult = await PublishTranscodeEvent(trackEntity, cancellationToken); - if (!publishTranscodeResult.IsSuccess) - { - await RollbackTrackAfterPublishFailureAsync(trackEntity, fullPictureKey, fullAudioKey, cancellationToken); + logger.LogError(exception, "Failed to save track {Title}", request.Title); return Result.Error($"Failed to create track for {request.Title}"); } return Result.Created(TrackApplicationResponse.FromEntity(trackEntity)); } - async Task UploadFilesAsync( - CreateTrackCommand request, - string pictureKey, - string audioKey, - CancellationToken cancellationToken) - { - try - { - await storageHandler.UploadFileAsync( - request.Picture.FileStream, - request.Picture.ContentType, - storageConfiguration.Bucket, - pictureKey, - cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to upload picture for track {Title}", request.Title); - return Result.Error($"Failed to upload picture for {request.Title}"); - } - - try - { - await storageHandler.UploadFileAsync( - request.Audio.FileStream, - request.Audio.ContentType, - storageConfiguration.Bucket, - audioKey, - cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to upload audio for track {Title}", request.Title); - await RollbackFilesAsync(pictureKey, audioKey, cancellationToken); - return Result.Error($"Failed to upload audio for {request.Title}"); - } - return Result.Success(); - } - - async Task RollbackFilesAsync(string pictureKey, string audioKey, CancellationToken cancellationToken) - { - await storageHandler.RemoveFileAsync(storageConfiguration.Bucket, pictureKey, cancellationToken); - await storageHandler.RemoveFileAsync(storageConfiguration.Bucket, audioKey, cancellationToken); - logger.LogInformation("Rolled back uploaded files {PictureKey}, {AudioKey}", pictureKey, audioKey); - } - - async Task PublishUpdateEvent(Track track, CancellationToken cancellationToken) + async Task PublishUpdateEvent(Guid userId, Track track, CancellationToken cancellationToken) { try { await eventBus.PublishAsync(new UpdateTrackPictureEvent( track.Id, storageConfiguration.Bucket, - trackConfiguration.Routes.BuildOriginalPicturePath(track.OriginalPictureName), + trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.OriginalPictureName), new ImageSize( trackConfiguration.Routes.SmallPicturesPath, trackConfiguration.PicturesSizes.SmallPictureWidth, @@ -142,7 +94,7 @@ namespace Musify.Application.Tracks.Handler } } - async Task PublishTranscodeEvent(Track track, CancellationToken cancellationToken) + async Task PublishTranscodeEvent(Guid userId, Track track, CancellationToken cancellationToken) { var destinationFolderAudio = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); @@ -151,7 +103,7 @@ namespace Musify.Application.Tracks.Handler await eventBus.PublishAsync(new UpdateTrackAudioEvent( track.Id, storageConfiguration.Bucket, - trackConfiguration.Routes.BuildOriginalAudioPath(track.OriginalAudioName), + trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.OriginalAudioName), storageConfiguration.Bucket, destinationFolderAudio), cancellationToken); return Result.Success(); @@ -163,30 +115,5 @@ namespace Musify.Application.Tracks.Handler } } - async Task RollbackTrackAfterPublishFailureAsync( - Track track, - string pictureKey, - string audioKey, - CancellationToken cancellationToken) - { - try - { - database.Tracks.Remove(track); - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to rollback track {TrackId} persistence after publish failure", track.Id); - } - - try - { - await RollbackFilesAsync(pictureKey, audioKey, cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to rollback uploaded files after publish failure for track {TrackId}", track.Id); - } - } } } \ No newline at end of file diff --git a/Application/Tracks/Handler/DeleteTrackCommandHandler.cs b/Application/Tracks/Handler/DeleteTrackCommandHandler.cs index 8c78e75..bd07a1d 100644 --- a/Application/Tracks/Handler/DeleteTrackCommandHandler.cs +++ b/Application/Tracks/Handler/DeleteTrackCommandHandler.cs @@ -40,12 +40,21 @@ namespace Musify.Application.Tracks.Handler return Result.Conflict("Track is currently being processed and cannot be deleted"); } - var previousPicturesStatus = track.PicturesProcessingStatus; - var previousAudioStatus = track.AudioTranscodeProcessingStatus; - track.PicturesProcessingStatus = ProcessingStatus.Processing; track.AudioTranscodeProcessingStatus = ProcessingStatus.Processing; + try + { + await eventBus.PublishAsync( + new DeleteTrackEvent(track.Id, request.UserId), + cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId); + return Result.Error($"Failed to delete track {request.TrackId}"); + } + try { await database.SaveChangesAsync(cancellationToken); @@ -56,31 +65,6 @@ namespace Musify.Application.Tracks.Handler return Result.Error($"Failed to delete track {request.TrackId}"); } - try - { - await eventBus.PublishAsync( - new DeleteTrackEvent(track.Id, request.UserId), - cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId); - - track.PicturesProcessingStatus = previousPicturesStatus; - track.AudioTranscodeProcessingStatus = previousAudioStatus; - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception rollbackException) - { - logger.LogError(rollbackException, "Failed to rollback track {TrackId} processing flags after publish failure", request.TrackId); - } - - return Result.Error($"Failed to delete track {request.TrackId}"); - } - return Result.NoContent(); } } diff --git a/Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs b/Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs new file mode 100644 index 0000000..d4c4d68 --- /dev/null +++ b/Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs @@ -0,0 +1,110 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Abstractions.Infrastructure; +using Musify.Application.Configuration; +using Musify.Application.Tracks.Commands; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks.Handler +{ + public class RequestTrackUploadUrlsCommandHandler( + IDatabase database, + IStorageService storageService, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration) + : ICommandHandler> + { + static readonly string[] AllowedPictureExtensions = [".webp", ".png", ".jpg", ".jpeg"]; + static readonly string[] AllowedAudioExtensions = [".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".opus"]; + const int ExpiresInSeconds = 600; + + public async ValueTask> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) + { + logger.LogWarning("User {UserId} not found", request.UserId); + return Result.NotFound($"User {request.UserId} not found"); + } + + var pictureExtensionResult = ValidateExtension(request.PictureFileType, AllowedPictureExtensions); + if (!pictureExtensionResult.IsSuccess) + return Result.Invalid(pictureExtensionResult.Errors.Select(error => new ValidationError(error)).ToArray()); + + var audioExtensionResult = ValidateExtension(request.AudioFileType, AllowedAudioExtensions); + if (!audioExtensionResult.IsSuccess) + return Result.Invalid(audioExtensionResult.Errors.Select(error => new ValidationError(error)).ToArray()); + + if (string.IsNullOrWhiteSpace(request.PictureContentType)) + return Result.Invalid(new ValidationError("PictureContentType is required")); + + if (string.IsNullOrWhiteSpace(request.AudioContentType)) + return Result.Invalid(new ValidationError("AudioContentType is required")); + + var pictureName = Guid.NewGuid() + pictureExtensionResult.Value; + var audioName = Guid.NewGuid() + audioExtensionResult.Value; + + var pictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureName); + var audioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioName); + + try + { + var pictureUploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + pictureKey, + request.PictureContentType, + TimeSpan.FromSeconds(ExpiresInSeconds), + cancellationToken); + + var audioUploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + audioKey, + request.AudioContentType, + TimeSpan.FromSeconds(ExpiresInSeconds), + cancellationToken); + + return Result.Success(new TrackUploadUrlsResponse( + storageConfiguration.Bucket, + pictureKey, + pictureName, + request.PictureContentType, + pictureUploadUrl, + audioKey, + audioName, + request.AudioContentType, + audioUploadUrl, + ExpiresInSeconds)); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate track upload URLs for user {UserId}", request.UserId); + return Result.Error("Failed to generate upload URLs"); + } + } + + static Result ValidateExtension(string fileType, string[] allowedExtensions) + { + if (string.IsNullOrWhiteSpace(fileType)) + return Result.Error("FileType is required"); + + var extension = fileType.Trim(); + if (!extension.StartsWith('.')) + extension = "." + extension; + + extension = extension.ToLowerInvariant(); + + if (extension.Contains('/') || extension.Contains('\\')) + return Result.Error("Invalid FileType"); + + if (!allowedExtensions.Contains(extension)) + return Result.Error($"Unsupported FileType '{extension}'"); + + return Result.Success(extension); + } + } +} diff --git a/Application/Tracks/Responses/TrackUploadUrlsResponse.cs b/Application/Tracks/Responses/TrackUploadUrlsResponse.cs new file mode 100644 index 0000000..cc5eb04 --- /dev/null +++ b/Application/Tracks/Responses/TrackUploadUrlsResponse.cs @@ -0,0 +1,14 @@ +namespace Musify.Application.Tracks.Responses +{ + public record TrackUploadUrlsResponse( + string Bucket, + string PictureKey, + string PictureName, + string PictureContentType, + string PictureUploadUrl, + string AudioKey, + string AudioName, + string AudioContentType, + string AudioUploadUrl, + int ExpiresInSeconds); +} diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj index fde65d6..8599ea5 100644 --- a/Infrastructure/Infrastructure.csproj +++ b/Infrastructure/Infrastructure.csproj @@ -16,6 +16,7 @@ + diff --git a/Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs b/Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs index a10c283..286feff 100644 --- a/Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs +++ b/Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs @@ -17,17 +17,20 @@ namespace Musify.Infrastructure.MassTransit.Activities.Files var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.FilePathVariable); ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); var fileName = Path.GetFileName(sourceFilePath); + var destinationKey = string.Join('/', new[] { executeContext.Arguments.DestinationRoute, fileName } + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); try { await storageHandler.UploadFileAsync( sourceFilePath, executeContext.Arguments.DestinationBucket, - Path.Combine(executeContext.Arguments.DestinationRoute, fileName), + destinationKey, executeContext.CancellationToken); return executeContext.CompletedWithVariables(new UploadFileToBucketLog( executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationRoute)); + destinationKey)); } catch (Exception exception) { diff --git a/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs index 6175262..2e4bf08 100644 --- a/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs +++ b/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs @@ -33,11 +33,11 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks try { - await RemovePicturesAsync(track, executeContext.CancellationToken); + await RemovePicturesAsync(track, executeContext.Arguments.UserId, executeContext.CancellationToken); track.PicturesProcessingStatus = ProcessingStatus.Completed; await database.SaveChangesAsync(executeContext.CancellationToken); - await RemoveAudiosAsync(track, executeContext.CancellationToken); + await RemoveAudiosAsync(track, executeContext.Arguments.UserId, executeContext.CancellationToken); track.AudioTranscodeProcessingStatus = ProcessingStatus.Completed; await database.SaveChangesAsync(executeContext.CancellationToken); @@ -66,9 +66,9 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks } } - async Task RemovePicturesAsync(Track track, CancellationToken cancellationToken) + async Task RemovePicturesAsync(Track track, Guid userId, CancellationToken cancellationToken) { - await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalPicturePath(track.OriginalPictureName), cancellationToken); + await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.OriginalPictureName), cancellationToken); if (!track.IsPicturesProcessed) { @@ -83,9 +83,9 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks await RemoveFileAsync(trackConfiguration.Routes.BuildLargePicturePath(track.LargePictureName), cancellationToken); } - async Task RemoveAudiosAsync(Track track, CancellationToken cancellationToken) + async Task RemoveAudiosAsync(Track track, Guid userId, CancellationToken cancellationToken) { - await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalAudioPath(track.OriginalAudioName), cancellationToken); + await RemoveFileAsync(trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.OriginalAudioName), cancellationToken); if (!track.IsAudioProcessed) { diff --git a/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Endpoints.cs b/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Endpoints.cs index 023ea42..82e8d35 100644 --- a/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Endpoints.cs +++ b/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Endpoints.cs @@ -1,5 +1,6 @@ using MassTransit; using Musify.Infrastructure.MassTransit.Consumers; +using Musify.Infrastructure.Persistence; namespace Musify.Infrastructure.MassTransit { @@ -12,7 +13,18 @@ namespace Musify.Infrastructure.MassTransit where TConsumer : class, IConsumer { busFactoryConfigurator.ReceiveEndpoint(queueName, endpointConfigurator => - endpointConfigurator.ConfigureConsumer(busRegistrationContext)); + { + endpointConfigurator.UseMessageRetry(retryConfigurator => + retryConfigurator.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromSeconds(1), + maxInterval: TimeSpan.FromSeconds(30), + intervalDelta: TimeSpan.FromSeconds(2))); + + endpointConfigurator.UseEntityFrameworkOutbox(busRegistrationContext); + + endpointConfigurator.ConfigureConsumer(busRegistrationContext); + }); } static void ConfigureExecuteActivityEndpoint( @@ -24,6 +36,15 @@ namespace Musify.Infrastructure.MassTransit { busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.ExecuteQueue(endpointName), endpointConfigurator => { + endpointConfigurator.UseMessageRetry(retryConfigurator => + retryConfigurator.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromSeconds(1), + maxInterval: TimeSpan.FromSeconds(30), + intervalDelta: TimeSpan.FromSeconds(2))); + + endpointConfigurator.UseEntityFrameworkOutbox(busRegistrationContext); + endpointConfigurator.ExecuteActivityHost(busRegistrationContext); }); } @@ -37,14 +58,34 @@ namespace Musify.Infrastructure.MassTransit where TLog : class { busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.ExecuteQueue(endpointName), endpointConfigurator => + { + endpointConfigurator.UseMessageRetry(retryConfigurator => + retryConfigurator.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromSeconds(1), + maxInterval: TimeSpan.FromSeconds(30), + intervalDelta: TimeSpan.FromSeconds(2))); + + endpointConfigurator.UseEntityFrameworkOutbox(busRegistrationContext); + endpointConfigurator.ExecuteActivityHost( EndpointHelper.BuildCompensateActivityUri(endpointName), - busRegistrationContext) - ); + busRegistrationContext); + }); busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.CompensateQueue(endpointName), endpointConfigurator => - endpointConfigurator.CompensateActivityHost(busRegistrationContext) - ); + { + endpointConfigurator.UseMessageRetry(retryConfigurator => + retryConfigurator.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromSeconds(1), + maxInterval: TimeSpan.FromSeconds(30), + intervalDelta: TimeSpan.FromSeconds(2))); + + endpointConfigurator.UseEntityFrameworkOutbox(busRegistrationContext); + + endpointConfigurator.CompensateActivityHost(busRegistrationContext); + }); } } } diff --git a/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs b/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs index f6a8317..1bc01e6 100644 --- a/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Musify.Application.Abstractions.Infrastructure; using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.Persistence; namespace Musify.Infrastructure.MassTransit { @@ -18,6 +19,15 @@ namespace Musify.Infrastructure.MassTransit serviceDescriptors.AddScoped(); serviceDescriptors.AddMassTransit(options => { + options.AddEntityFrameworkOutbox(outbox => + { + outbox.UsePostgres(); + outbox.UseBusOutbox(); + + outbox.QueryDelay = TimeSpan.FromSeconds(1); + outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); + }); + options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => ConfigureRabbitMqHost( busFactoryConfigurator, @@ -43,6 +53,15 @@ namespace Musify.Infrastructure.MassTransit serviceDescriptors.AddMassTransit(options => { + options.AddEntityFrameworkOutbox(outbox => + { + outbox.UsePostgres(); + outbox.UseBusOutbox(); + + outbox.QueryDelay = TimeSpan.FromSeconds(1); + outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); + }); + RegisterConsumersAndActivities(options); options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => diff --git a/Infrastructure/MassTransit/EventBus/MassTransitEventBus.cs b/Infrastructure/MassTransit/EventBus/MassTransitEventBus.cs index 7692f01..50b398c 100644 --- a/Infrastructure/MassTransit/EventBus/MassTransitEventBus.cs +++ b/Infrastructure/MassTransit/EventBus/MassTransitEventBus.cs @@ -6,7 +6,7 @@ namespace Musify.Infrastructure.MassTransit public class MassTransitEventBus(IPublishEndpoint publishEndpoint) : IEventBus { - public async Task PublishAsync(T message, CancellationToken cancellationToken) + public async Task PublishAsync(T message, CancellationToken cancellationToken) where T : class { ArgumentNullException.ThrowIfNull(message, nameof(message)); diff --git a/Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs b/Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs index ef80864..fec9322 100644 --- a/Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs +++ b/Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs @@ -99,7 +99,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders AddResizeActivity( routingSlipBuilder, ActivityNames.ResizeMedium, - ActivityNames.ResizeMedium, + ActivityNames.UploadMedium, bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, medium); diff --git a/Infrastructure/Persistence/Database.cs b/Infrastructure/Persistence/Database.cs index 94874bb..778fc2d 100644 --- a/Infrastructure/Persistence/Database.cs +++ b/Infrastructure/Persistence/Database.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using MassTransit; using Musify.Application.Abstractions.Infrastructure; using Musify.Domain.Entities; using Musify.Infrastructure.Configuration; @@ -13,6 +14,15 @@ namespace Musify.Infrastructure.Persistence optionsBuilder.UseNpgsql(configuration.ConnectionString); } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.AddInboxStateEntity(); + modelBuilder.AddOutboxMessageEntity(); + modelBuilder.AddOutboxStateEntity(); + } + public DbSet Users => Set(); public DbSet Tracks => Set(); diff --git a/Infrastructure/Persistence/Migrations/20260514095113_AddMassTransitOutbox.Designer.cs b/Infrastructure/Persistence/Migrations/20260514095113_AddMassTransitOutbox.Designer.cs new file mode 100644 index 0000000..ce2d9a3 --- /dev/null +++ b/Infrastructure/Persistence/Migrations/20260514095113_AddMassTransitOutbox.Designer.cs @@ -0,0 +1,512 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260514095113_AddMassTransitOutbox")] + partial class AddMassTransitOutbox + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Infrastructure/Persistence/Migrations/20260514095113_AddMassTransitOutbox.cs b/Infrastructure/Persistence/Migrations/20260514095113_AddMassTransitOutbox.cs new file mode 100644 index 0000000..c4074bd --- /dev/null +++ b/Infrastructure/Persistence/Migrations/20260514095113_AddMassTransitOutbox.cs @@ -0,0 +1,142 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddMassTransitOutbox : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InboxState", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MessageId = table.Column(type: "uuid", nullable: false), + ConsumerId = table.Column(type: "uuid", nullable: false), + LockId = table.Column(type: "uuid", nullable: false), + RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: true), + Received = table.Column(type: "timestamp with time zone", nullable: false), + ReceiveCount = table.Column(type: "integer", nullable: false), + ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true), + Consumed = table.Column(type: "timestamp with time zone", nullable: true), + Delivered = table.Column(type: "timestamp with time zone", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_InboxState", x => x.Id); + table.UniqueConstraint("AK_InboxState_MessageId_ConsumerId", x => new { x.MessageId, x.ConsumerId }); + }); + + migrationBuilder.CreateTable( + name: "OutboxState", + columns: table => new + { + OutboxId = table.Column(type: "uuid", nullable: false), + LockId = table.Column(type: "uuid", nullable: false), + RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: true), + Created = table.Column(type: "timestamp with time zone", nullable: false), + Delivered = table.Column(type: "timestamp with time zone", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxState", x => x.OutboxId); + }); + + migrationBuilder.CreateTable( + name: "OutboxMessage", + columns: table => new + { + SequenceNumber = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + EnqueueTime = table.Column(type: "timestamp with time zone", nullable: true), + SentTime = table.Column(type: "timestamp with time zone", nullable: false), + Headers = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + InboxMessageId = table.Column(type: "uuid", nullable: true), + InboxConsumerId = table.Column(type: "uuid", nullable: true), + OutboxId = table.Column(type: "uuid", nullable: true), + MessageId = table.Column(type: "uuid", nullable: false), + ContentType = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + MessageType = table.Column(type: "text", nullable: false), + Body = table.Column(type: "text", nullable: false), + ConversationId = table.Column(type: "uuid", nullable: true), + CorrelationId = table.Column(type: "uuid", nullable: true), + InitiatorId = table.Column(type: "uuid", nullable: true), + RequestId = table.Column(type: "uuid", nullable: true), + SourceAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + DestinationAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ResponseAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + FaultAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxMessage", x => x.SequenceNumber); + table.ForeignKey( + name: "FK_OutboxMessage_InboxState_InboxMessageId_InboxConsumerId", + columns: x => new { x.InboxMessageId, x.InboxConsumerId }, + principalTable: "InboxState", + principalColumns: new[] { "MessageId", "ConsumerId" }); + table.ForeignKey( + name: "FK_OutboxMessage_OutboxState_OutboxId", + column: x => x.OutboxId, + principalTable: "OutboxState", + principalColumn: "OutboxId"); + }); + + migrationBuilder.CreateIndex( + name: "IX_InboxState_Delivered", + table: "InboxState", + column: "Delivered"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_EnqueueTime", + table: "OutboxMessage", + column: "EnqueueTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_ExpirationTime", + table: "OutboxMessage", + column: "ExpirationTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_InboxMessageId_InboxConsumerId_SequenceNumber", + table: "OutboxMessage", + columns: new[] { "InboxMessageId", "InboxConsumerId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_OutboxId_SequenceNumber", + table: "OutboxMessage", + columns: new[] { "OutboxId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxState_Created", + table: "OutboxState", + column: "Created"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OutboxMessage"); + + migrationBuilder.DropTable( + name: "InboxState"); + + migrationBuilder.DropTable( + name: "OutboxState"); + } + } +} diff --git a/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 0f04a84..12b1182 100644 --- a/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -22,6 +22,174 @@ namespace Musify.Infrastructure.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => { b.Property("Id") @@ -256,6 +424,18 @@ namespace Musify.Infrastructure.Persistence.Migrations b.ToTable("UserHasTrack"); }); + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => { b.HasOne("Musify.Domain.Entities.User", "User") diff --git a/Infrastructure/Services/StorageService.cs b/Infrastructure/Services/StorageService.cs index 1f170ef..806a271 100644 --- a/Infrastructure/Services/StorageService.cs +++ b/Infrastructure/Services/StorageService.cs @@ -48,6 +48,27 @@ namespace Musify.Infrastructure.Services return await amazonS3.GetPreSignedURLAsync(request); } + public async Task GetUploadUrlAsync( + string bucket, + string key, + string contentType, + TimeSpan expirationTime, + CancellationToken cancellationToken) + { + var request = new GetPreSignedUrlRequest + { + BucketName = bucket, + Key = key, + Expires = DateTime.UtcNow + expirationTime, + Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS, + Verb = HttpVerb.PUT, + ContentType = contentType + }; + + logger.LogDebug("Generating pre-signed upload URL for {Bucket}/{Key}", bucket, key); + return await amazonS3.GetPreSignedURLAsync(request); + } + public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) { var request = new DeleteObjectRequest @@ -74,7 +95,9 @@ namespace Musify.Infrastructure.Services string fileName = Path.GetFileName(file); string filePath = Path.Combine(sourceDirectory, file); - var key = Path.Combine(route, fileName); + var key = string.Join('/', new[] { route, fileName } + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); var upload = new Upload { Id = Guid.NewGuid(), diff --git a/docs/changes-summary.md b/docs/changes-summary.md new file mode 100644 index 0000000..c6c57bc --- /dev/null +++ b/docs/changes-summary.md @@ -0,0 +1,165 @@ +# MusifyBackend — Resumen de cambios (2026-05-14) + +Este documento resume **todos los cambios aplicados hasta ahora** en la rama `master` durante esta iteración. + +> Nota: El proyecto compila en `Release` al cierre de estos cambios. + +--- + +## 1) Mensajería robusta: MassTransit + EF Outbox + +**Objetivo**: hacer la publicación de eventos confiable y transaccional (DB + mensajes) y tolerar redelivery en consumidores. + +### Cambios principales +- Se integró **MassTransit EntityFramework Outbox** con PostgreSQL (`UsePostgres` + `UseBusOutbox`). +- Se añadieron entidades/tablas de **Inbox/Outbox** en el `DbContext`. +- Se generó y añadió una **migración** para las tablas de outbox/inbox. +- Se ajustó la configuración de endpoints para aplicar: + - **Retry** (exponential backoff) + - **Idempotencia** en consumo usando `UseEntityFrameworkOutbox()` +- Se ajustó el orden de operaciones para el patrón outbox: **publicar evento y luego `SaveChanges`**. + +### Archivos involucrados (principalmente) +- `Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs` +- `Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Endpoints.cs` +- `Infrastructure/Persistence/Database.cs` +- `Infrastructure/Persistence/Migrations/20260514095113_AddMassTransitOutbox.cs` +- `Application/Abstractions/Infrastructure/IEventBus.cs` +- `Infrastructure/MassTransit/EventBus/MassTransitEventBus.cs` + +--- + +## 2) Workflows: Courier RoutingSlip para imágenes y audio + +**Objetivo**: mantener el pipeline asíncrono (descarga, resize/transcode, upload, update de entidades) con reintentos e idempotencia. + +### Fix importante en workflow de imágenes +- Se corrigió el nombre de actividad del paso de **Upload del tamaño Medium** (antes duplicaba el nombre de `ResizeMedium`). + +Archivos: +- `Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs` +- `Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs` + +### Fix importante en compensación de uploads +- `UploadFileToBucketActivity` ahora registra correctamente el **object key completo** (ruta + filename) para que la compensación borre el objeto correcto. + +Archivo: +- `Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs` + +--- + +## 3) Uploads (Opción B): Presigned PUT URL + Create/Update con nombres + +**Objetivo**: separar la subida de binarios (S3) de la operación de dominio (Create/Update), evitando rollbacks complejos y haciendo el flujo más robusto. + +### Endpoint/Commands añadidos (request presigned URL) +- Playlist picture: + - `Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs` + - `Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs` + - `Application/PlayLists/Responses/PlayListPictureUploadResponse.cs` +- Track picture + audio: + - `Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs` + - `Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs` + - `Application/Tracks/Responses/TrackUploadUrlsResponse.cs` + +### Storage: presigned PUT +- Se añadió soporte para generar **presigned PUT URLs**: + - `Application/Abstractions/Infrastructure/IStorageService.cs` + - `Infrastructure/Services/StorageService.cs` (`GetUploadUrlAsync`) + +--- + +## 4) Limpieza: eliminar V1 y normalizar nombres (V2 → “normal”) + +**Objetivo**: eliminar comandos/handlers “V1” (subida directa por stream/multipart) y dejar un único set de comandos “normales” con el contrato final. + +### Resultado +- `CreatePlayListCommand`, `UpdatePlayListCommand` y `CreateTrackCommand` quedaron con el contrato nuevo (nombres/metadata, no streams). +- Se eliminaron archivos `*V2*` ya innecesarios. + +Archivos clave: +- `Application/PlayLists/Commands/CreatePlayListCommand.cs` +- `Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs` +- `Application/PlayLists/Commands/UpdatePlayListCommand.cs` +- `Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs` +- `Application/Tracks/Commands/CreateTrackCommand.cs` +- `Application/Tracks/Handler/CreateTrackCommandHandler.cs` + +--- + +## 5) Prefijo de seguridad: `uploads/{userId}/...` para originales + +**Objetivo**: reducir superficie (y colisiones) haciendo que los objetos originales queden naturalmente “namespaced” por usuario. + +### Cambios +- Las **keys de originales** (playlist original picture, track original picture, track original audio) ahora se generan bajo: + - `uploads/{userId}/...` +- Se añadieron overloads de rutas para construir keys con `userId`. +- Los handlers de request presigned URL ahora generan keys usando el prefijo con `userId`. +- Los eventos de workflows (sourceKey) se publican con la key prefijada. +- El borrado de playlist borra la key original prefijada. +- El borrado de track (`DeleteTrackActivity`) borra originales usando el `userId` presente en `DeleteTrackArguments`. + +Archivos: +- `Application/Configuration/PlayListConfiguration.cs` +- `Application/Configuration/TrackConfiguration.cs` +- `Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs` +- `Application/Tracks/Handler/RequestTrackUploadUrlsCommandHandler.cs` +- `Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs` +- `Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs` +- `Application/Tracks/Handler/CreateTrackCommandHandler.cs` +- `Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs` +- `Infrastructure/MassTransit/Activities/Tracks/DeleteTrackActivity.cs` + +--- + +## 6) Normalización de separadores en keys S3 (evitar `\\` en Windows) + +**Objetivo**: evitar que `Path.Combine` introduzca separadores `\\` en keys S3 (lo cual es incorrecto/inesperado en S3, y rompe interoperabilidad). + +### Cambios +- Se reemplazaron combinaciones de keys que usaban `Path.Combine` por joins con `/` en puntos críticos. + +Archivos: +- `Application/Configuration/PlayListConfiguration.cs` +- `Application/Configuration/TrackConfiguration.cs` +- `Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs` +- `Infrastructure/Services/StorageService.cs` + +--- + +## 7) Validaciones en Application eliminadas (delegadas a API) + +**Objetivo**: evitar duplicar validaciones de formato (extensiones/strings) si la capa API ya se encarga. + +### Cambios +- Se eliminó el helper de validación creado inicialmente y los checks en handlers. + +Archivos: +- Eliminado: `Application/Validation/UploadObjectNameValidator.cs` +- Ajustados: + - `Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs` + - `Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs` + - `Application/Tracks/Handler/CreateTrackCommandHandler.cs` + +--- + +## 8) Documento de mitigaciones (presigned uploads) + +Se añadió documentación de hardening/mitigaciones contra abuso/spam en endpoints presigned. + +Archivo: +- `docs/presigned-upload-hardening.md` + +--- + +## 9) Notas de compatibilidad / impacto + +- Este cambio **rompe compatibilidad** con la API anterior si existían endpoints que aceptaban multipart/streams para Create/Update (ahora se espera el flujo presigned y luego Create/Update con nombres). +- Los objetos originales ahora viven bajo `uploads/{userId}/...`. Objetos antiguos fuera de ese prefijo pueden quedar huérfanos si ya existían de antes. + +--- + +## 10) Estado final + +- `dotnet build MusifyBackend.slnx -c Release` ✅ diff --git a/docs/delete-workflows-plan.md b/docs/delete-workflows-plan.md new file mode 100644 index 0000000..5b92b0c --- /dev/null +++ b/docs/delete-workflows-plan.md @@ -0,0 +1,175 @@ +# Plan — Unificar Deletes con Workflows (RoutingSlip) + +## Contexto + +Actualmente hay dos problemas de diseño en los deletes: + +**PlayLists:** el borrado dispara múltiples `RemoveFileEvent` sueltos desde `DeletePlayListCommandHandler` (4 llamadas separadas para original/small/medium/large) y luego elimina la entidad en DB. Sin workflow, sin trazabilidad. + +**Tracks:** ya existe un workflow (`DeleteTrackEvent` → consumer → RoutingSlip → `DeleteTrackActivity`), pero `DeleteTrackActivity` hace **demasiado en un único paso**: carga la entidad, borra todos los archivos de S3 (pictures + audios), y elimina de DB. Esto rompe el principio de actividades granulares y reutilizables, hace que el slip no sea "seguible" paso a paso, y dificulta los reintentos parciales. + +La idea es **homogeneizar y corregir ambos**: que los deletes de Track y PlayList sigan un RoutingSlip con actividades pequeñas, ordenadas, repetibles e independientes. + +--- + +## Objetivos + +- **Un único camino** de borrado por aggregate (Track / PlayList), basado en **RoutingSlip con actividades granulares**. +- El handler de comando hace lo mínimo: validar ownership/reglas, cambiar estado a `Removing`, y publicar el evento de borrado (outbox). +- El workflow ejecuta pasos **ordenados, reutilizables y reiniciables** individualmente. +- **Reutilizar** `RemoveFileFromBucketActivity` (ya existente) para cada archivo a borrar, en lugar de lógica ad-hoc dentro de una activity monolítica. +- Eliminar `RemoveFileEvent`, `RemoveFileConsumer` y su registration si no tienen otros usos. + +No objetivos (por ahora): +- No introducir observabilidad extra (tracing/telemetría) fuera de lo ya existente. + +--- + +## Problemas del enfoque actual + +### PlayLists +- El handler implementa orquestación (decide qué archivos borrar y cómo) → acoplamiento alto. +- Publica múltiples `RemoveFileEvent` sueltos, sin agruparlos en un proceso único. +- No hay log de RoutingSlip que permita seguir el progreso/estado. + +### Tracks ← **nuevo problema identificado** +- `DeleteTrackActivity` es monolítica: carga entidad, borra pictures, borra audios, elimina de DB, todo en un solo `Execute`. +- No reutiliza `RemoveFileFromBucketActivity` ya existente. +- Si falla a mitad, no hay granularidad de reintento (no se sabe qué paso falló). +- El RoutingSlip tiene un único nodo → no es realmente un "slip" con pasos. + +--- + +## Diseño propuesto + +### Actividades compartidas / reutilizables + +Ya existe (y se mantiene): +- `RemoveFileFromBucketActivity` — borra un único objeto de S3. Se usará N veces en cada slip. + +Se añaden actividades de DB compartibles por aggregate: +- `MarkAsRemovingActivity` — cambia estado de la entidad a `Removing` y persiste. (O una por aggregate si la lógica difiere.) +- `DeleteTrackFromDbActivity` — elimina el Track de EF (cascade elimina relaciones). +- `DeletePlayListFromDbActivity` — elimina la PlayList de EF (cascade elimina relaciones). + +### Estructura del RoutingSlip para Track + +``` +DeleteTrackEvent + └─► DeleteTrackConsumer + └─► DeleteTrackRoutingSlipBuilder + ├─ MarkTrackAsRemovingActivity (cambia estado → Removing, persiste) + ├─ RemoveFileFromBucketActivity (original picture) + ├─ RemoveFileFromBucketActivity (small picture) ← solo si IsPicturesProcessed + ├─ RemoveFileFromBucketActivity (medium picture) ← solo si IsPicturesProcessed + ├─ RemoveFileFromBucketActivity (large picture) ← solo si IsPicturesProcessed + ├─ RemoveFileFromBucketActivity (original audio) + ├─ RemoveFileFromBucketActivity (processed audio) ← solo si IsAudioProcessed + └─ DeleteTrackFromDbActivity (Remove() + SaveChanges) +``` + +> El builder carga la entidad para construir el plan (keys condicionales) antes de arrancar el slip. Las actividades de S3 son idempotentes (NotFound → log Information + continue). + +### Estructura del RoutingSlip para PlayList + +``` +DeletePlayListEvent + └─► DeletePlayListConsumer + └─► DeletePlayListRoutingSlipBuilder + ├─ MarkPlayListAsRemovingActivity (cambia estado → Removing, persiste) + ├─ RemoveFileFromBucketActivity (original — uploads/{userId}/...) ← solo si no es preset + ├─ RemoveFileFromBucketActivity (small) ← solo si no es preset + ├─ RemoveFileFromBucketActivity (medium) ← solo si no es preset + ├─ RemoveFileFromBucketActivity (large) ← solo si no es preset + └─ DeletePlayListFromDbActivity (Remove() + SaveChanges, cascade elimina PlayListHasTrack) +``` + +### Handler de comando (ambos aggregates) + +```csharp +// DeletePlayListCommandHandler — ANTES +await PublishRemoveFileEventsAsync(...); // 4 eventos sueltos +database.PlayLists.Remove(playlist); +await database.SaveChangesAsync(); + +// DESPUÉS +await eventBus.PublishAsync(new DeletePlayListEvent(command.PlayListId, command.UserId)); +// Nada más. El workflow se encarga de todo. +``` + +```csharp +// DeleteTrackCommandHandler — ANTES +// (ya publicaba DeleteTrackEvent, se mantiene igual) +await eventBus.PublishAsync(new DeleteTrackEvent(command.TrackId, command.UserId)); + +// La diferencia es que ahora el slip tiene pasos granulares, no una activity monolítica. +``` + +--- + +## Reglas de negocio a preservar + +- Ownership se valida en el handler del comando (igual que ahora), antes de publicar el evento. +- PlayList: solo borrar original/sizes si no son presets. +- PlayList: el original está bajo `uploads/{userId}/...` (cambio reciente). +- Track: respetar estados de procesamiento — si audio está `Pending/Processing`, el handler ya bloquea el delete antes de llegar al workflow. +- EF cascade elimina `PlayListHasTrack` al borrar la PlayList → no hace falta activity explícita para ello. + +--- + +## Idempotencia / reintentos + +- RoutingSlip + reintentos de endpoint ya configurados en MassTransit. +- Operaciones S3: idempotentes por diseño. Si el key no existe → log `Information` y `Completed()` (no throw). +- DB: si la entidad ya no existe cuando llega `DeleteTrackFromDbActivity` / `DeletePlayListFromDbActivity`, el workflow se considera `Completed` (misma lógica que ahora). +- Estado `Removing`: permite identificar entidades en proceso de borrado si el sistema se reinicia a mitad del slip. + +--- + +## Plan de implementación (paso a paso) + +### 1. Inventario +- Confirmar todos los usos de `RemoveFileEvent` / `RemoveFileConsumer` en la solución. +- Si no tienen otros usos fuera de playlist delete → marcarlos para eliminación en el paso 7. + +### 2. Refactorizar Track delete ← **nuevo** +- Crear `MarkTrackAsRemovingActivity` (execute-activity): carga Track, setea estado `Removing`, persiste. +- Crear `DeleteTrackFromDbActivity` (execute-activity): carga Track, llama `Remove()`, persiste. +- Actualizar `DeleteTrackRoutingSlipBuilder`: + - Carga el Track para calcular el plan (keys condicionales según `IsPicturesProcessed`, `IsAudioProcessed`). + - Encadena: `MarkTrackAsRemoving` → N × `RemoveFileFromBucket` → `DeleteTrackFromDb`. +- Eliminar `DeleteTrackActivity` monolítica (ya no se usa). +- Ajustar registration DI de MassTransit (quitar activity vieja, registrar las nuevas). + +### 3. Crear PlayList delete: evento + consumer + builder +- Añadir `DeletePlayListEvent(PlayListId, UserId)` en `Application/Events`. +- Crear `DeletePlayListConsumer` en Infrastructure: recibe el evento y ejecuta el builder. +- Crear `DeletePlayListRoutingSlipBuilder`: + - Carga la PlayList para calcular el plan (keys condicionales por presets, ruta de original). + - Encadena: `MarkPlayListAsRemoving` → N × `RemoveFileFromBucket` → `DeletePlayListFromDb`. + +### 4. Crear actividades de PlayList +- `MarkPlayListAsRemovingActivity` (execute-activity): carga PlayList, setea `Removing`, persiste. +- `DeletePlayListFromDbActivity` (execute-activity): carga PlayList, llama `Remove()`, persiste (cascade). + +### 5. Actualizar `RemoveFileFromBucketActivity` para NotFound +- Si el SDK lanza excepción por key inexistente → capturar, log `Information("Key {Key} not found, skipping")`, retornar `Completed()`. +- Si es otro error → log `Error` + throw (reintento normal). + +### 6. Cambiar `DeletePlayListCommandHandler` +- Reemplazar `PublishRemoveFileEventsAsync` por `eventBus.PublishAsync(new DeletePlayListEvent(...))`. +- No publicar más `RemoveFileEvent` para playlist delete. + +### 7. Ajustar DI / registration MassTransit +- Registrar: `DeletePlayListConsumer`, `MarkPlayListAsRemovingActivity`, `DeletePlayListFromDbActivity`. +- Registrar: `MarkTrackAsRemovingActivity`, `DeleteTrackFromDbActivity`. +- Desregistrar: `DeleteTrackActivity` (monolítica), y si procede `RemoveFileConsumer`. + +### 8. Eliminar código muerto +- `DeleteTrackActivity` monolítica. +- `RemoveFileEvent` y `RemoveFileConsumer` (si no tienen otros usos). +- Endpoints y registrations asociados. + +### 9. Build y verificación +- `dotnet build -c Release` sin warnings de referencias muertas. +- Smoke test manual: borrar un Track y una PlayList, verificar logs de cada activity en orden. \ No newline at end of file diff --git a/docs/presigned-upload-hardening.md b/docs/presigned-upload-hardening.md new file mode 100644 index 0000000..a31b247 --- /dev/null +++ b/docs/presigned-upload-hardening.md @@ -0,0 +1,95 @@ +# Hardening: abuso de pre-signed uploads (Opción B) + +## Contexto +Con la Opción B el backend genera URLs pre-firmadas (pre-signed) para que el cliente suba ficheros directamente a S3, y luego el backend crea/actualiza entidades en DB referenciando `pictureName/audioName`. + +Esto mejora latencia y descarga la API, pero abre un vector de abuso: un usuario puede llamar muchas veces a `RequestPlayListPictureUpload` / `RequestTrackUploadUrls` y subir muchos objetos a S3 sin llegar a crear la entidad final (objetos “huérfanos”). + +## Qué pasa hoy si el usuario spamea +- Se generan muchas URLs pre-firmadas (costo bajo pero no cero). +- El usuario puede subir muchos objetos a S3 sin relación con una entidad en DB. +- El impacto principal es: + - coste/uso de S3 (storage + requests) + - posible saturación del endpoint de generación de URLs + +## Objetivo +Reducir abuso y coste, manteniendo: +- UX simple +- backend sin streams +- workflows asíncronos + MassTransit Outbox + +## Mitigaciones recomendadas (pueden coexistir) + +### 1) Rate limiting por usuario/IP (primera línea de defensa) +Aplicar limitación en el host/API (no en `Application`). Ejemplos: +- `N` requests/min por `UserId` +- opcional: `M` requests/min por IP + +Ventajas: +- Corta abuso sin tocar DB/S3. +- No cambia dominio. + +Nota: requiere acceso al proyecto host (controllers/minimal API) para aplicarlo. + +### 2) “Upload intents” en DB (control y conciliación) +Persistir una intención de upload cuando se genera un pre-signed URL. + +Modelo sugerido (mínimo): +- `UploadIntent` + - `Id` + - `UserId` + - `Bucket` + - `Key` + - `ContentType` + - `ExpiresAt` + - `Status`: `Issued | Consumed | Expired` + - `CreatedAt` + +Reglas: +- Al pedir URL (Request*Upload*): + - insertar intent `Issued` + - imponer límite por usuario: p.ej. `MaxActiveIntentsPerUser = 5` + - si supera el límite: devolver `429 Too Many Requests` (o `Conflict`) según convención del API +- Al crear/actualizar entidad (Create*/Update* V2): + - validar que el `pictureName/audioName` recibido corresponde a un intent `Issued` del mismo `UserId` y no expirado + - marcar intent como `Consumed` + +Ventajas: +- Previene spam con reglas duras y auditables. +- Permite cleanup de huérfanos con certeza. + +Coste: +- entidad + migración + lógica de validación. + +### 3) Limitar el scope del pre-signed URL (reducción de daño) +Aplicar restricciones conservadoras: +- Reducir TTL: + - `ExpiresInSeconds` más bajo (p.ej. 60–120s en vez de 600s) +- Validar/limitar: + - `ContentType` permitido + - extensiones permitidas (`FileType`) +- Usar keys temporales por usuario: + - `PlayLists/TempUploads/{userId}/{guid}.{ext}` + - `Tracks/TempUploads/{userId}/{guid}.{ext}` + +Luego, en el workflow/worker: +- mover/copiar de `TempUploads/...` a la ruta final (`OriginalPictures/...` / `OriginalAudios/...`) +- borrar el objeto temporal + +Ventajas: +- Cleanup masivo sencillo (por prefijo `TempUploads/{userId}`) +- Reduce el impacto de huérfanos. + +## Orden de implementación sugerido +1) Rate limiting en API (rápido + mayor impacto) +2) Upload intents en DB (límite de activos + validación al crear/actualizar) +3) Reducir TTL y mover a rutas temporales (`TempUploads/...`) + cleanup + +## Parámetros recomendados (config) +- `UploadUrlExpiresInSeconds` (default: 120) +- `MaxActiveUploadIntentsPerUser` (default: 5) +- `AllowedPictureExtensions` / `AllowedAudioExtensions` +- `AllowedContentTypes` (lista blanca) + +## Notas +- Estas defensas complementan el Outbox: el Outbox garantiza entrega de eventos tras commit, pero no evita el abuso de generación de URLs o almacenamiento de objetos huérfanos. diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..1ab5b91 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "skills": { + "csharp-async": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "213d4b0921836fdf1ea3d218bc3625f48d149463f9bc765aac0eb343f31cfe37" + }, + "csharp-docs": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "03ba5f2f605625202803d1cc79353e10b4f3ff720b93992aeecc8d88a1e44720" + }, + "csharp-mstest": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "abb51cb248cb42f92716dc6568da8c33a9ea23af9a34155ef3ba156a61d9693d" + }, + "csharp-nunit": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "0493f7b95a519b13685335a32c9ff86e4eaca53e44a4f6795d11f72f899c683d" + }, + "csharp-tunit": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "5c6139f8cf75687df89a7c8c8fe8834676ffb496b632433973c84e128b52c2f6" + }, + "csharp-xunit": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "808c2d86afd16bccaba5f6d499361eb1da4d3e687a3305aa376ad4d40382e27d" + }, + "dotnet-best-practices": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "98bd60f0529ffbb6b6319e61e952ce11ae9deb99106212d1070007bc8ae6708f" + }, + "dotnet-design-pattern-review": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "60e201c6f3bd2832ed3834a8bb75143b2e6647e6026c5cbdad62f7706700c496" + }, + "dotnet-upgrade": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "77925fe0898ceeab04c574390815e7500a2426a26036a357325ce751f23cce71" + } + } +}