diff --git a/assets/mocks/recipes-detail-01.jpeg b/assets/mocks/recipes-detail-01.jpeg
new file mode 100644
index 00000000..0bad9f0f
Binary files /dev/null and b/assets/mocks/recipes-detail-01.jpeg differ
diff --git a/assets/mocks/recipes-detail-step-01.jpeg b/assets/mocks/recipes-detail-step-01.jpeg
new file mode 100644
index 00000000..73088325
Binary files /dev/null and b/assets/mocks/recipes-detail-step-01.jpeg differ
diff --git a/assets/mocks/recipes-detail-step-02.jpeg b/assets/mocks/recipes-detail-step-02.jpeg
new file mode 100644
index 00000000..cfdfeb2e
Binary files /dev/null and b/assets/mocks/recipes-detail-step-02.jpeg differ
diff --git a/assets/mocks/recipes-detail-step-03.jpeg b/assets/mocks/recipes-detail-step-03.jpeg
new file mode 100644
index 00000000..0f05c194
Binary files /dev/null and b/assets/mocks/recipes-detail-step-03.jpeg differ
diff --git a/assets/mocks/recipes-overview-01.jpeg b/assets/mocks/recipes-overview-01.jpeg
new file mode 100644
index 00000000..591ef03a
Binary files /dev/null and b/assets/mocks/recipes-overview-01.jpeg differ
diff --git a/generate-client.sh b/generate-client.sh
index 616a6ab0..0ee765ee 100644
--- a/generate-client.sh
+++ b/generate-client.sh
@@ -7,11 +7,14 @@ CLIENT_CLASS="BackendClient"
CLIENT_NAMESPACE="HomeBook.Client"
OPENAPI_FILE="./source/HomeBook.Backend/HomeBook.Backend.json"
CLIENT_OUTPUT_DIR="./source/HomeBook.Client"
+CLIENT_CSPROJ="HomeBook.Client.csproj"
-# build backend
+# Clean output dir except the client csproj
+find "${CLIENT_OUTPUT_DIR}" -mindepth 1 ! -name "${CLIENT_CSPROJ}" -exec rm -rf {} +
-dotnet restore "${BACKEND_CSPROJ}"
-dotnet build "${BACKEND_CSPROJ}" --no-restore -c Release
+# build backend
+dotnet build "${BACKEND_CSPROJ}" -c Debug
+dotnet build "${BACKEND_CSPROJ}" -c Release
# install/update kiota
dotnet tool install --global Microsoft.OpenApi.Kiota
@@ -26,3 +29,6 @@ kiota generate \
echo "Client generation completed successfully!"
echo "Output directory: ${CLIENT_OUTPUT_DIR}"
+
+# nuget restore client
+dotnet restore "${CLIENT_OUTPUT_DIR}/${CLIENT_CSPROJ}"
diff --git a/homebook.slnx b/homebook.slnx
index 4d35a74e..957d0989 100644
--- a/homebook.slnx
+++ b/homebook.slnx
@@ -1,37 +1,42 @@
-
-
-
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
@@ -63,6 +68,6 @@
-
+
\ No newline at end of file
diff --git a/source/HomeBook.Backend.Abstractions/Contracts/IEndpointDataAccessor.cs b/source/HomeBook.Backend.Abstractions/Contracts/IEndpointDataAccessor.cs
new file mode 100644
index 00000000..11d27f84
--- /dev/null
+++ b/source/HomeBook.Backend.Abstractions/Contracts/IEndpointDataAccessor.cs
@@ -0,0 +1,5 @@
+namespace HomeBook.Backend.Abstractions.Contracts;
+
+public interface IEndpointDataAccessor
+{
+}
diff --git a/source/HomeBook.Backend.Abstractions/Contracts/ISearchAggregationResult.cs b/source/HomeBook.Backend.Abstractions/Contracts/ISearchAggregationResult.cs
new file mode 100644
index 00000000..69228683
--- /dev/null
+++ b/source/HomeBook.Backend.Abstractions/Contracts/ISearchAggregationResult.cs
@@ -0,0 +1,8 @@
+namespace HomeBook.Backend.Abstractions.Contracts;
+
+public interface ISearchAggregationResult
+{
+ public string ModuleKey { get; }
+ public int TotalCount { get; }
+ public IEnumerable Items { get; }
+}
diff --git a/source/HomeBook.Backend.Abstractions/Contracts/ISearchProvider.cs b/source/HomeBook.Backend.Abstractions/Contracts/ISearchProvider.cs
new file mode 100644
index 00000000..64b60bec
--- /dev/null
+++ b/source/HomeBook.Backend.Abstractions/Contracts/ISearchProvider.cs
@@ -0,0 +1,19 @@
+namespace HomeBook.Backend.Abstractions.Contracts;
+
+///
+///
+///
+public interface ISearchProvider
+{
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task>
+ SearchAsync(string query,
+ Guid userId,
+ CancellationToken cancellationToken = default);
+}
diff --git a/source/HomeBook.Backend.Abstractions/Contracts/ISearchRegistrationFactory.cs b/source/HomeBook.Backend.Abstractions/Contracts/ISearchRegistrationFactory.cs
new file mode 100644
index 00000000..ce8b1818
--- /dev/null
+++ b/source/HomeBook.Backend.Abstractions/Contracts/ISearchRegistrationFactory.cs
@@ -0,0 +1,6 @@
+namespace HomeBook.Backend.Abstractions.Contracts;
+
+public interface ISearchRegistrationFactory
+{
+ ISearchProvider CreateSearchProvider();
+}
diff --git a/source/HomeBook.Backend.Abstractions/Contracts/ISearchRegistrationInitiator.cs b/source/HomeBook.Backend.Abstractions/Contracts/ISearchRegistrationInitiator.cs
new file mode 100644
index 00000000..d7b3f9fb
--- /dev/null
+++ b/source/HomeBook.Backend.Abstractions/Contracts/ISearchRegistrationInitiator.cs
@@ -0,0 +1,7 @@
+namespace HomeBook.Backend.Abstractions.Contracts;
+
+public interface ISearchRegistrationInitiator
+{
+ void AddModule(string moduleId);
+ void AddServiceProvider(IServiceProvider serviceProvider);
+}
diff --git a/source/HomeBook.Backend.Abstractions/Contracts/ISearchResultItem.cs b/source/HomeBook.Backend.Abstractions/Contracts/ISearchResultItem.cs
new file mode 100644
index 00000000..e956308c
--- /dev/null
+++ b/source/HomeBook.Backend.Abstractions/Contracts/ISearchResultItem.cs
@@ -0,0 +1,10 @@
+namespace HomeBook.Backend.Abstractions.Contracts;
+
+public interface ISearchResultItem
+{
+ string Title { get; }
+ string? Description { get; }
+ string Url { get; }
+ string Icon { get; }
+ string Color { get; }
+}
diff --git a/source/HomeBook.Backend.Abstractions/Contracts/IUserProvider.cs b/source/HomeBook.Backend.Abstractions/Contracts/IUserProvider.cs
index 80146b7b..a8c319a6 100644
--- a/source/HomeBook.Backend.Abstractions/Contracts/IUserProvider.cs
+++ b/source/HomeBook.Backend.Abstractions/Contracts/IUserProvider.cs
@@ -52,4 +52,13 @@ Task ContainsUserAsync(string username,
Task UpdateAdminFlag(Guid userId,
bool isAdmin,
CancellationToken cancellationToken);
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task GetUserByIdAsync(Guid userId,
+ CancellationToken cancellationToken);
}
diff --git a/source/HomeBook.Backend.Abstractions/HomeBook.Backend.Abstractions.csproj b/source/HomeBook.Backend.Abstractions/HomeBook.Backend.Abstractions.csproj
index c9dd3514..cb6aaa84 100644
--- a/source/HomeBook.Backend.Abstractions/HomeBook.Backend.Abstractions.csproj
+++ b/source/HomeBook.Backend.Abstractions/HomeBook.Backend.Abstractions.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/source/HomeBook.Backend.Core.Account/HomeBook.Backend.Core.Account.csproj b/source/HomeBook.Backend.Core.Account/HomeBook.Backend.Core.Account.csproj
index 598fd476..2f271a90 100644
--- a/source/HomeBook.Backend.Core.Account/HomeBook.Backend.Core.Account.csproj
+++ b/source/HomeBook.Backend.Core.Account/HomeBook.Backend.Core.Account.csproj
@@ -14,8 +14,8 @@
-
-
+
+
diff --git a/source/HomeBook.Backend.Core.Account/JwtService.cs b/source/HomeBook.Backend.Core.Account/JwtService.cs
index 0d8a63a4..dbcf85e7 100644
--- a/source/HomeBook.Backend.Core.Account/JwtService.cs
+++ b/source/HomeBook.Backend.Core.Account/JwtService.cs
@@ -14,16 +14,20 @@ namespace HomeBook.Backend.Core.Account;
///
public class JwtService(IConfiguration configuration) : IJwtService
{
- private readonly string _secretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey is required");
- private readonly string _issuer = configuration["Jwt:Issuer"] ?? "HomeBook";
- private readonly string _audience = configuration["Jwt:Audience"] ?? "HomeBook";
- private readonly int _expirationMinutes = int.Parse(configuration["Jwt:ExpirationMinutes"] ?? "60");
+ private readonly string _secretKey = configuration["Jwt:SecretKey"]
+ ?? throw new InvalidOperationException("JWT SecretKey is required");
+
+ private readonly string _issuer = configuration["Jwt:Issuer"]
+ ?? "HomeBook";
+
+ private readonly string _audience = configuration["Jwt:Audience"]
+ ?? "HomeBook";
+
+ private readonly int _expirationMinutes = int.Parse(configuration["Jwt:ExpirationMinutes"]
+ ?? "60");
///
- public JwtTokenResult GenerateToken(Guid userId, string username)
- {
- return GenerateToken(userId, username, false);
- }
+ public JwtTokenResult GenerateToken(Guid userId, string username) => GenerateToken(userId, username, false);
///
public JwtTokenResult GenerateToken(Guid userId, string username, bool isAdmin)
@@ -36,14 +40,18 @@ public JwtTokenResult GenerateToken(Guid userId, string username, bool isAdmin)
[
new(ClaimTypes.NameIdentifier, userId.ToString()),
new(ClaimTypes.Name, username),
- new(ClaimTypes.Role, isAdmin ? "Admin" : "User"),
- new("IsAdmin", isAdmin.ToString(), ClaimValueTypes.Boolean),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(JwtRegisteredClaimNames.Iat,
new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64)
];
+ if (isAdmin)
+ {
+ claims = claims.Append(new Claim(ClaimTypes.Role, isAdmin ? "Admin" : "User")).ToArray();
+ claims = claims.Append(new Claim("IsAdmin", isAdmin.ToString(), ClaimValueTypes.Boolean)).ToArray();
+ }
+
JwtSecurityToken token = new(
issuer: _issuer,
audience: _audience,
@@ -71,8 +79,8 @@ public bool ValidateToken(string token)
{
try
{
- var tokenHandler = new JwtSecurityTokenHandler();
- var key = Encoding.UTF8.GetBytes(_secretKey);
+ JwtSecurityTokenHandler tokenHandler = new();
+ byte[] key = Encoding.UTF8.GetBytes(_secretKey);
tokenHandler.ValidateToken(token,
new TokenValidationParameters
@@ -101,10 +109,10 @@ public bool ValidateToken(string token)
{
try
{
- var tokenHandler = new JwtSecurityTokenHandler();
- var key = Encoding.UTF8.GetBytes(_secretKey);
+ JwtSecurityTokenHandler tokenHandler = new();
+ byte[] key = Encoding.UTF8.GetBytes(_secretKey);
- var principal = tokenHandler.ValidateToken(token,
+ ClaimsPrincipal? principal = tokenHandler.ValidateToken(token,
new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
@@ -118,7 +126,7 @@ public bool ValidateToken(string token)
},
out SecurityToken validatedToken);
- var userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier);
+ Claim? userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier);
if (userIdClaim != null && Guid.TryParse(userIdClaim.Value, out Guid userId))
{
return userId;
diff --git a/source/HomeBook.Backend.Core.DataProvider/HomeBook.Backend.Core.DataProvider.csproj b/source/HomeBook.Backend.Core.DataProvider/HomeBook.Backend.Core.DataProvider.csproj
index 869637ab..79de7b7b 100644
--- a/source/HomeBook.Backend.Core.DataProvider/HomeBook.Backend.Core.DataProvider.csproj
+++ b/source/HomeBook.Backend.Core.DataProvider/HomeBook.Backend.Core.DataProvider.csproj
@@ -8,11 +8,10 @@
-
-
+
diff --git a/source/HomeBook.Backend.Core.DataProvider/UserProvider.cs b/source/HomeBook.Backend.Core.DataProvider/UserProvider.cs
index 65643f0a..69c29690 100644
--- a/source/HomeBook.Backend.Core.DataProvider/UserProvider.cs
+++ b/source/HomeBook.Backend.Core.DataProvider/UserProvider.cs
@@ -82,4 +82,10 @@ public Task UpdateAdminFlag(Guid userId,
},
cancellationToken);
}
+
+ ///
+ public async Task GetUserByIdAsync(Guid userId,
+ CancellationToken cancellationToken) =>
+ (await userRepository.GetUserByIdAsync(userId,
+ cancellationToken))?.ToUserInfo();
}
diff --git a/source/HomeBook.Backend.Core.Finances/Extensions/ServiceCollectionExtensions.cs b/source/HomeBook.Backend.Core.Finances/Extensions/ServiceCollectionExtensions.cs
deleted file mode 100644
index 837ed346..00000000
--- a/source/HomeBook.Backend.Core.Finances/Extensions/ServiceCollectionExtensions.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using FluentValidation;
-using HomeBook.Backend.Abstractions;
-using HomeBook.Backend.Core.Finances.Contracts;
-using HomeBook.Backend.Core.Finances.Validators;
-using HomeBook.Backend.Data.Entities;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace HomeBook.Backend.Core.Finances.Extensions;
-
-public static class ServiceCollectionExtensions
-{
- public static IServiceCollection AddBackendCoreFinances(this IServiceCollection services,
- IConfiguration configuration,
- InstanceStatus instanceStatus)
- {
- services.AddScoped();
- services.AddScoped();
-
- services.AddSingleton, SavingGoalValidator>();
-
- return services;
- }
-}
diff --git a/source/HomeBook.Backend.Core.Finances/Mappings/SavingGoalMappings.cs b/source/HomeBook.Backend.Core.Finances/Mappings/SavingGoalMappings.cs
deleted file mode 100644
index df644c70..00000000
--- a/source/HomeBook.Backend.Core.Finances/Mappings/SavingGoalMappings.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using HomeBook.Backend.Core.Finances.Models;
-
-namespace HomeBook.Backend.Core.Finances.Mappings;
-
-public static class SavingGoalMappings
-{
- public static SavingGoalDto ToDto(this Data.Entities.SavingGoal savingGoal)
- {
- return new SavingGoalDto(
- savingGoal.Id,
- savingGoal.Name,
- savingGoal.Color,
- savingGoal.Icon ?? string.Empty,
- savingGoal.TargetAmount,
- savingGoal.CurrentAmount,
- savingGoal.MonthlyPayment,
- (DTOs.Enums.InterestRateOptions)savingGoal.InterestRateOption,
- savingGoal.InterestRate,
- savingGoal.TargetDate);
- }
-}
diff --git a/source/HomeBook.Backend.Core.Kitchen/Contracts/IRecipesProvider.cs b/source/HomeBook.Backend.Core.Kitchen/Contracts/IRecipesProvider.cs
deleted file mode 100644
index 6251ffd1..00000000
--- a/source/HomeBook.Backend.Core.Kitchen/Contracts/IRecipesProvider.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using HomeBook.Backend.Core.Kitchen.Models;
-
-namespace HomeBook.Backend.Core.Kitchen.Contracts;
-
-public interface IRecipesProvider
-{
- ///
- ///
- ///
- ///
- ///
- ///
- Task GetRecipesAsync(string searchFilter,
- CancellationToken cancellationToken);
-
- ///
- ///
- ///
- ///
- ///
- ///
- Task GetRecipeByIdAsync(Guid id,
- CancellationToken cancellationToken);
-
- ///
- ///
- ///
- ///
- ///
- ///
- Task CreateAsync(string name,
- CancellationToken cancellationToken);
-
- ///
- ///
- ///
- ///
- ///
- ///
- Task DeleteAsync(Guid id,
- CancellationToken cancellationToken);
-
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- Task UpdateNameAsync(Guid id,
- string name,
- CancellationToken cancellationToken);
-}
diff --git a/source/HomeBook.Backend.Core.Kitchen/Extensions/ServiceCollectionExtensions.cs b/source/HomeBook.Backend.Core.Kitchen/Extensions/ServiceCollectionExtensions.cs
deleted file mode 100644
index 8044ea1a..00000000
--- a/source/HomeBook.Backend.Core.Kitchen/Extensions/ServiceCollectionExtensions.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using HomeBook.Backend.Abstractions;
-using HomeBook.Backend.Core.Kitchen.Contracts;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace HomeBook.Backend.Core.Kitchen.Extensions;
-
-public static class ServiceCollectionExtensions
-{
- public static IServiceCollection AddBackendCoreKitchen(this IServiceCollection services,
- IConfiguration configuration,
- InstanceStatus instanceStatus)
- {
- services.AddScoped();
-
- return services;
- }
-}
diff --git a/source/HomeBook.Backend.Core.Kitchen/Mappings/RecipeMappings.cs b/source/HomeBook.Backend.Core.Kitchen/Mappings/RecipeMappings.cs
deleted file mode 100644
index 5cb72c2a..00000000
--- a/source/HomeBook.Backend.Core.Kitchen/Mappings/RecipeMappings.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using HomeBook.Backend.Core.Kitchen.Models;
-
-namespace HomeBook.Backend.Core.Kitchen.Mappings;
-
-public static class RecipeMappings
-{
- public static RecipeDto ToDto(this Data.Entities.Recipe recipe)
- {
- return new RecipeDto(
- recipe.Id,
- recipe.Name,
- recipe.NormalizedName);
- }
-}
diff --git a/source/HomeBook.Backend.Core.Kitchen/Models/RecipeDto.cs b/source/HomeBook.Backend.Core.Kitchen/Models/RecipeDto.cs
deleted file mode 100644
index 2d682b16..00000000
--- a/source/HomeBook.Backend.Core.Kitchen/Models/RecipeDto.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-namespace HomeBook.Backend.Core.Kitchen.Models;
-
-public record RecipeDto(Guid Id,
- string Name,
- string NormalizedName);
diff --git a/source/HomeBook.Backend.Core.Kitchen/RecipesProvider.cs b/source/HomeBook.Backend.Core.Kitchen/RecipesProvider.cs
deleted file mode 100644
index 2a2bbcb0..00000000
--- a/source/HomeBook.Backend.Core.Kitchen/RecipesProvider.cs
+++ /dev/null
@@ -1,78 +0,0 @@
-using HomeBook.Backend.Core.Kitchen.Contracts;
-using HomeBook.Backend.Core.Kitchen.Mappings;
-using HomeBook.Backend.Core.Kitchen.Models;
-using HomeBook.Backend.Data.Contracts;
-using HomeBook.Backend.Data.Entities;
-using Microsoft.Extensions.Logging;
-
-namespace HomeBook.Backend.Core.Kitchen;
-
-///
-public class RecipesProvider(
- ILogger logger,
- IRecipesRepository recipesRepository) : IRecipesProvider
-{
- ///
- public async Task GetRecipesAsync(string searchFilter,
- CancellationToken cancellationToken)
- {
- logger.LogInformation("Retrieving meals with search filter: {SearchFilter}",
- searchFilter);
-
- IEnumerable recipeEntities = await recipesRepository.GetAsync(searchFilter,
- cancellationToken);
- RecipeDto[] recipes = recipeEntities
- .Select(m => m.ToDto())
- .ToArray();
-
- return recipes;
- }
-
- ///
- public async Task GetRecipeByIdAsync(Guid id,
- CancellationToken cancellationToken) =>
- (await recipesRepository.GetByIdAsync(id,
- cancellationToken))?.ToDto();
-
- ///
- public async Task CreateAsync(string name,
- CancellationToken cancellationToken)
- {
- Recipe entity = new()
- {
- Name = name
- };
-
- // TODO: validator
-
- Guid entityId = await recipesRepository
- .CreateOrUpdateAsync(entity,
- cancellationToken);
- return entityId;
- }
-
- ///
- public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) =>
- await recipesRepository.DeleteAsync(id,
- cancellationToken);
-
- ///
- public async Task UpdateNameAsync(Guid id,
- string name,
- CancellationToken cancellationToken)
- {
- Recipe entity = await recipesRepository.GetByIdAsync(id,
- cancellationToken)
- ?? throw new KeyNotFoundException(
- $"Recipe with id {id} not found");
-
- entity.Name = name;
-
- // TODO: validator
-
- await recipesRepository
- .CreateOrUpdateAsync(entity,
- cancellationToken);
- return;
- }
-}
diff --git a/source/HomeBook.Backend.Core.Modules/HomeBook.Backend.Core.Modules.csproj b/source/HomeBook.Backend.Core.Modules/HomeBook.Backend.Core.Modules.csproj
new file mode 100644
index 00000000..17b910f6
--- /dev/null
+++ b/source/HomeBook.Backend.Core.Modules/HomeBook.Backend.Core.Modules.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/source/HomeBook.Backend/OpenApi/Description.cs b/source/HomeBook.Backend.Core.Modules/OpenApi/Description.cs
similarity index 88%
rename from source/HomeBook.Backend/OpenApi/Description.cs
rename to source/HomeBook.Backend.Core.Modules/OpenApi/Description.cs
index 36b19f1b..b664c96d 100644
--- a/source/HomeBook.Backend/OpenApi/Description.cs
+++ b/source/HomeBook.Backend.Core.Modules/OpenApi/Description.cs
@@ -1,4 +1,4 @@
-namespace HomeBook.Backend.OpenApi;
+namespace HomeBook.Backend.Core.Modules.OpenApi;
public class Description(params string[] descriptionLines)
{
diff --git a/source/HomeBook.Backend/Utilities/ClaimsPrincipalUtilities.cs b/source/HomeBook.Backend.Core.Modules/Utilities/ClaimsPrincipalUtilities.cs
similarity index 90%
rename from source/HomeBook.Backend/Utilities/ClaimsPrincipalUtilities.cs
rename to source/HomeBook.Backend.Core.Modules/Utilities/ClaimsPrincipalUtilities.cs
index 9fbc2c17..54640c9d 100644
--- a/source/HomeBook.Backend/Utilities/ClaimsPrincipalUtilities.cs
+++ b/source/HomeBook.Backend.Core.Modules/Utilities/ClaimsPrincipalUtilities.cs
@@ -1,6 +1,6 @@
using System.Security.Claims;
-namespace HomeBook.Backend.Utilities;
+namespace HomeBook.Backend.Core.Modules.Utilities;
public static class ClaimsPrincipalUtilities
{
diff --git a/source/HomeBook.Backend.Core.Search/Extensions/ServiceCollectionExtensions.cs b/source/HomeBook.Backend.Core.Search/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..c221e425
--- /dev/null
+++ b/source/HomeBook.Backend.Core.Search/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,20 @@
+using HomeBook.Backend.Abstractions;
+using HomeBook.Backend.Abstractions.Contracts;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HomeBook.Backend.Core.Search.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddBackendCoreSearch(this IServiceCollection services,
+ IConfiguration configuration,
+ InstanceStatus instanceStatus)
+ {
+ services.AddSingleton();
+ services.AddSingleton(x => x.GetRequiredService());
+ services.AddSingleton(x => x.GetRequiredService());
+
+ return services;
+ }
+}
diff --git a/source/HomeBook.Backend.Core.Search/HomeBook.Backend.Core.Search.csproj b/source/HomeBook.Backend.Core.Search/HomeBook.Backend.Core.Search.csproj
new file mode 100644
index 00000000..bad3431d
--- /dev/null
+++ b/source/HomeBook.Backend.Core.Search/HomeBook.Backend.Core.Search.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/source/HomeBook.Backend.Core.Search/Models/SearchAggregationResult.cs b/source/HomeBook.Backend.Core.Search/Models/SearchAggregationResult.cs
new file mode 100644
index 00000000..4dbcc9ac
--- /dev/null
+++ b/source/HomeBook.Backend.Core.Search/Models/SearchAggregationResult.cs
@@ -0,0 +1,8 @@
+using HomeBook.Backend.Abstractions.Contracts;
+
+namespace HomeBook.Backend.Core.Search.Models;
+
+public record SearchAggregationResult(
+ string ModuleKey,
+ int TotalCount,
+ IEnumerable Items) : ISearchAggregationResult;
diff --git a/source/HomeBook.Backend.Core.Search/SearchProvider.cs b/source/HomeBook.Backend.Core.Search/SearchProvider.cs
new file mode 100644
index 00000000..8e09759e
--- /dev/null
+++ b/source/HomeBook.Backend.Core.Search/SearchProvider.cs
@@ -0,0 +1,61 @@
+using HomeBook.Backend.Abstractions.Contracts;
+using HomeBook.Backend.Core.Search.Models;
+using HomeBook.Backend.Modules.Abstractions;
+using Microsoft.Extensions.Logging;
+
+namespace HomeBook.Backend.Core.Search;
+
+///
+public class SearchProvider(
+ ILogger logger,
+ IEnumerable modules) : ISearchProvider
+{
+ ///
+ public async Task> SearchAsync(string query,
+ Guid userId,
+ CancellationToken cancellationToken = default)
+ {
+ IEnumerable> moduleSearchTasks = modules
+ .Select(async module =>
+ {
+ try
+ {
+ logger.LogDebug("Requesting module {Module} for search query '{Query}'",
+ module.Name,
+ query);
+
+ SearchResult result = await module.SearchAsync(query,
+ userId,
+ cancellationToken);
+
+ logger.LogDebug("Module {Module} returned search result with {Count} items for query '{Query}'",
+ module.Name,
+ result.Items.Count(),
+ query);
+
+ SearchAggregationResult moduleSearchResult = new(module.Key,
+ result.TotalCount,
+ result.Items);
+ return moduleSearchResult;
+ }
+ catch (OperationCanceledException)
+ {
+ // Task was cancelled, return null
+ return null;
+ }
+ catch (Exception err)
+ {
+ logger.LogError(err,
+ "Error while requesting module {Module} for search query '{Query}'",
+ module.Name,
+ query);
+
+ return null;
+ }
+ })
+ .Where(result => result is not null)!;
+
+ IReadOnlyList searchResults = await Task.WhenAll(moduleSearchTasks.ToArray());
+ return searchResults;
+ }
+}
diff --git a/source/HomeBook.Backend.Core.Search/SearchRegistrationFactory.cs b/source/HomeBook.Backend.Core.Search/SearchRegistrationFactory.cs
new file mode 100644
index 00000000..cf19d337
--- /dev/null
+++ b/source/HomeBook.Backend.Core.Search/SearchRegistrationFactory.cs
@@ -0,0 +1,35 @@
+using HomeBook.Backend.Abstractions.Contracts;
+using HomeBook.Backend.Modules.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace HomeBook.Backend.Core.Search;
+
+public class SearchRegistrationFactory()
+ : ISearchRegistrationFactory,
+ ISearchRegistrationInitiator
+{
+ private IServiceProvider _serviceProvider = null!;
+ private readonly List _registeredModules = [];
+
+ public void AddModule(string moduleId) => _registeredModules.Add(moduleId);
+
+ public void AddServiceProvider(IServiceProvider serviceProvider) => _serviceProvider = serviceProvider;
+
+ public ISearchProvider CreateSearchProvider()
+ {
+ List modules = [];
+ foreach (string moduleId in _registeredModules)
+ {
+ IModule module = _serviceProvider.GetRequiredKeyedService(moduleId);
+ IBackendModuleSearchRegistrar registrar = (IBackendModuleSearchRegistrar)module;
+ modules.Add(registrar);
+ }
+
+ ILoggerFactory loggerFactory = _serviceProvider.GetRequiredService();
+ SearchProvider searchProvider = new(loggerFactory.CreateLogger(),
+ modules);
+
+ return searchProvider;
+ }
+}
diff --git a/source/HomeBook.Backend.Core/HomeBook.Backend.Core.csproj b/source/HomeBook.Backend.Core/HomeBook.Backend.Core.csproj
index 3f0fffac..6a1367cf 100644
--- a/source/HomeBook.Backend.Core/HomeBook.Backend.Core.csproj
+++ b/source/HomeBook.Backend.Core/HomeBook.Backend.Core.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/source/HomeBook.Backend.Core/StringNormalizer.cs b/source/HomeBook.Backend.Core/StringNormalizer.cs
index 9d497c3b..49ba5930 100644
--- a/source/HomeBook.Backend.Core/StringNormalizer.cs
+++ b/source/HomeBook.Backend.Core/StringNormalizer.cs
@@ -9,7 +9,6 @@ public string Normalize(string input)
if (string.IsNullOrWhiteSpace(input))
return string.Empty;
-
string normalized = input.Trim().ToLowerInvariant();
// Replace common diacritics with their base characters
diff --git a/source/HomeBook.Backend.DTOs/HomeBook.Backend.DTOs.csproj b/source/HomeBook.Backend.DTOs/HomeBook.Backend.DTOs.csproj
index 17b910f6..f61d0e78 100644
--- a/source/HomeBook.Backend.DTOs/HomeBook.Backend.DTOs.csproj
+++ b/source/HomeBook.Backend.DTOs/HomeBook.Backend.DTOs.csproj
@@ -6,4 +6,8 @@
enable
+
+
+
+
diff --git a/source/HomeBook.Backend.DTOs/Requests/Kitchen/CreateRecipeRequest.cs b/source/HomeBook.Backend.DTOs/Requests/Kitchen/CreateRecipeRequest.cs
deleted file mode 100644
index 10dfbe4c..00000000
--- a/source/HomeBook.Backend.DTOs/Requests/Kitchen/CreateRecipeRequest.cs
+++ /dev/null
@@ -1,3 +0,0 @@
-namespace HomeBook.Backend.DTOs.Requests.Kitchen;
-
-public record CreateRecipeRequest(string Name);
diff --git a/source/HomeBook.Backend.DTOs/Responses/Kitchen/RecipeResponse.cs b/source/HomeBook.Backend.DTOs/Responses/Kitchen/RecipeResponse.cs
deleted file mode 100644
index 679296b6..00000000
--- a/source/HomeBook.Backend.DTOs/Responses/Kitchen/RecipeResponse.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using System.Diagnostics;
-
-namespace HomeBook.Backend.DTOs.Responses.Kitchen;
-
-[DebuggerDisplay("{Name}")]
-public record RecipeResponse(
- Guid Id,
- string Name,
- string NormalizedName);
diff --git a/source/HomeBook.Backend.DTOs/Responses/Search/SearchResponse.cs b/source/HomeBook.Backend.DTOs/Responses/Search/SearchResponse.cs
new file mode 100644
index 00000000..dba2ec65
--- /dev/null
+++ b/source/HomeBook.Backend.DTOs/Responses/Search/SearchResponse.cs
@@ -0,0 +1,15 @@
+namespace HomeBook.Backend.DTOs.Responses.Search;
+
+public record SearchResponse(SearchModuleResponse[] SearchModuleResponses);
+
+public record SearchModuleResponse(
+ string ModuleKey,
+ int TotalCount,
+ IEnumerable Items);
+
+public record SearchItemResponse(
+ string Title,
+ string? Description,
+ string Url,
+ string Icon,
+ string Color);
diff --git a/source/HomeBook.Backend.Data.Mysql/HomeBook.Backend.Data.Mysql.csproj b/source/HomeBook.Backend.Data.Mysql/HomeBook.Backend.Data.Mysql.csproj
index 37fc593a..d70e322a 100644
--- a/source/HomeBook.Backend.Data.Mysql/HomeBook.Backend.Data.Mysql.csproj
+++ b/source/HomeBook.Backend.Data.Mysql/HomeBook.Backend.Data.Mysql.csproj
@@ -11,7 +11,7 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/source/HomeBook.Backend.Data.Mysql/Migrations/20251121154310_AddBasicPropertiesToRecipe.Designer.cs b/source/HomeBook.Backend.Data.Mysql/Migrations/20251121154310_AddBasicPropertiesToRecipe.Designer.cs
new file mode 100644
index 00000000..e3707ccc
--- /dev/null
+++ b/source/HomeBook.Backend.Data.Mysql/Migrations/20251121154310_AddBasicPropertiesToRecipe.Designer.cs
@@ -0,0 +1,275 @@
+//
+using System;
+using HomeBook.Backend.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace HomeBook.Backend.Data.Mysql.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20251121154310_AddBasicPropertiesToRecipe")]
+ partial class AddBasicPropertiesToRecipe
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Configuration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.ToTable("Configurations");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Ingredient", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique();
+
+ b.ToTable("Ingredients");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("CaloriesKcal")
+ .HasColumnType("int");
+
+ b.Property("Description")
+ .HasColumnType("longtext");
+
+ b.Property("DurationMinutes")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Servings")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.ToTable("Recipes");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeIngredient", b =>
+ {
+ b.Property("RecipeId")
+ .HasColumnType("char(36)");
+
+ b.Property("IngredientId")
+ .HasColumnType("char(36)");
+
+ b.Property("Quantity")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Unit")
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.HasKey("RecipeId", "IngredientId");
+
+ b.HasIndex("IngredientId");
+
+ b.ToTable("RecipeIngredients");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.SavingGoal", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(7)
+ .HasColumnType("varchar(7)");
+
+ b.Property("CurrentAmount")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Icon")
+ .HasColumnType("longtext");
+
+ b.Property("InterestRate")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("InterestRateOption")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MonthlyPayment")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("TargetAmount")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("TargetDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("SavingGoals");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime(6)");
+
+ b.Property("Disabled")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IsAdmin")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("PasswordHashType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.UserPreference", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.Property("Key")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("UserId", "Key");
+
+ b.ToTable("UserPreferences");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeIngredient", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.Ingredient", "Ingredient")
+ .WithMany("RecipeIngredients")
+ .HasForeignKey("IngredientId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("HomeBook.Backend.Data.Entities.Recipe", "Recipe")
+ .WithMany()
+ .HasForeignKey("RecipeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Ingredient");
+
+ b.Navigation("Recipe");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.SavingGoal", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.UserPreference", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Ingredient", b =>
+ {
+ b.Navigation("RecipeIngredients");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/source/HomeBook.Backend.Data.Mysql/Migrations/20251121154310_AddBasicPropertiesToRecipe.cs b/source/HomeBook.Backend.Data.Mysql/Migrations/20251121154310_AddBasicPropertiesToRecipe.cs
new file mode 100644
index 00000000..55c376b7
--- /dev/null
+++ b/source/HomeBook.Backend.Data.Mysql/Migrations/20251121154310_AddBasicPropertiesToRecipe.cs
@@ -0,0 +1,38 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace HomeBook.Backend.Data.Mysql.Migrations
+{
+ ///
+ public partial class AddBasicPropertiesToRecipe : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "Description",
+ table: "Recipes",
+ type: "longtext",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "Servings",
+ table: "Recipes",
+ type: "int",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Description",
+ table: "Recipes");
+
+ migrationBuilder.DropColumn(
+ name: "Servings",
+ table: "Recipes");
+ }
+ }
+}
diff --git a/source/HomeBook.Backend.Data.Mysql/Migrations/20251124002034_AddUserToRecipe.Designer.cs b/source/HomeBook.Backend.Data.Mysql/Migrations/20251124002034_AddUserToRecipe.Designer.cs
new file mode 100644
index 00000000..1f880caa
--- /dev/null
+++ b/source/HomeBook.Backend.Data.Mysql/Migrations/20251124002034_AddUserToRecipe.Designer.cs
@@ -0,0 +1,289 @@
+//
+using System;
+using HomeBook.Backend.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace HomeBook.Backend.Data.Mysql.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20251124002034_AddUserToRecipe")]
+ partial class AddUserToRecipe
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Configuration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.ToTable("Configurations");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Ingredient", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique();
+
+ b.ToTable("Ingredients");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("CaloriesKcal")
+ .HasColumnType("int");
+
+ b.Property("Description")
+ .HasColumnType("longtext");
+
+ b.Property("DurationMinutes")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Servings")
+ .HasColumnType("int");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Recipes");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeIngredient", b =>
+ {
+ b.Property("RecipeId")
+ .HasColumnType("char(36)");
+
+ b.Property("IngredientId")
+ .HasColumnType("char(36)");
+
+ b.Property("Quantity")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Unit")
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.HasKey("RecipeId", "IngredientId");
+
+ b.HasIndex("IngredientId");
+
+ b.ToTable("RecipeIngredients");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.SavingGoal", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(7)
+ .HasColumnType("varchar(7)");
+
+ b.Property("CurrentAmount")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Icon")
+ .HasColumnType("longtext");
+
+ b.Property("InterestRate")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("InterestRateOption")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MonthlyPayment")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("TargetAmount")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("TargetDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("SavingGoals");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime(6)");
+
+ b.Property("Disabled")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IsAdmin")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("PasswordHashType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.UserPreference", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.Property("Key")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("UserId", "Key");
+
+ b.ToTable("UserPreferences");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeIngredient", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.Ingredient", "Ingredient")
+ .WithMany("RecipeIngredients")
+ .HasForeignKey("IngredientId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("HomeBook.Backend.Data.Entities.Recipe", "Recipe")
+ .WithMany()
+ .HasForeignKey("RecipeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Ingredient");
+
+ b.Navigation("Recipe");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.SavingGoal", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.UserPreference", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Ingredient", b =>
+ {
+ b.Navigation("RecipeIngredients");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/source/HomeBook.Backend.Data.Mysql/Migrations/20251124002034_AddUserToRecipe.cs b/source/HomeBook.Backend.Data.Mysql/Migrations/20251124002034_AddUserToRecipe.cs
new file mode 100644
index 00000000..1671c620
--- /dev/null
+++ b/source/HomeBook.Backend.Data.Mysql/Migrations/20251124002034_AddUserToRecipe.cs
@@ -0,0 +1,49 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace HomeBook.Backend.Data.Mysql.Migrations
+{
+ ///
+ public partial class AddUserToRecipe : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "UserId",
+ table: "Recipes",
+ type: "char(36)",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Recipes_UserId",
+ table: "Recipes",
+ column: "UserId");
+
+ migrationBuilder.AddForeignKey(
+ name: "FK_Recipes_Users_UserId",
+ table: "Recipes",
+ column: "UserId",
+ principalTable: "Users",
+ principalColumn: "Id");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "FK_Recipes_Users_UserId",
+ table: "Recipes");
+
+ migrationBuilder.DropIndex(
+ name: "IX_Recipes_UserId",
+ table: "Recipes");
+
+ migrationBuilder.DropColumn(
+ name: "UserId",
+ table: "Recipes");
+ }
+ }
+}
diff --git a/source/HomeBook.Backend.Data.Mysql/Migrations/20251208213633_AddAdditionalDataToRecipe.Designer.cs b/source/HomeBook.Backend.Data.Mysql/Migrations/20251208213633_AddAdditionalDataToRecipe.Designer.cs
new file mode 100644
index 00000000..f9a67458
--- /dev/null
+++ b/source/HomeBook.Backend.Data.Mysql/Migrations/20251208213633_AddAdditionalDataToRecipe.Designer.cs
@@ -0,0 +1,301 @@
+//
+using System;
+using HomeBook.Backend.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace HomeBook.Backend.Data.Mysql.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20251208213633_AddAdditionalDataToRecipe")]
+ partial class AddAdditionalDataToRecipe
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Configuration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.ToTable("Configurations");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Ingredient", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique();
+
+ b.ToTable("Ingredients");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("CaloriesKcal")
+ .HasColumnType("int");
+
+ b.Property("Comments")
+ .HasColumnType("longtext");
+
+ b.Property("Description")
+ .HasColumnType("longtext");
+
+ b.Property("DurationCookingMinutes")
+ .HasColumnType("int");
+
+ b.Property("DurationRestingMinutes")
+ .HasColumnType("int");
+
+ b.Property("DurationWorkingMinutes")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Servings")
+ .HasColumnType("int");
+
+ b.Property("Source")
+ .HasColumnType("longtext");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Recipes");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeIngredient", b =>
+ {
+ b.Property("RecipeId")
+ .HasColumnType("char(36)");
+
+ b.Property("IngredientId")
+ .HasColumnType("char(36)");
+
+ b.Property("Quantity")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Unit")
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.HasKey("RecipeId", "IngredientId");
+
+ b.HasIndex("IngredientId");
+
+ b.ToTable("RecipeIngredients");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.SavingGoal", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(7)
+ .HasColumnType("varchar(7)");
+
+ b.Property("CurrentAmount")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Icon")
+ .HasColumnType("longtext");
+
+ b.Property("InterestRate")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("InterestRateOption")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MonthlyPayment")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("TargetAmount")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("TargetDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("SavingGoals");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime(6)");
+
+ b.Property("Disabled")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IsAdmin")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("PasswordHashType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.UserPreference", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.Property("Key")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("UserId", "Key");
+
+ b.ToTable("UserPreferences");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeIngredient", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.Ingredient", "Ingredient")
+ .WithMany("RecipeIngredients")
+ .HasForeignKey("IngredientId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("HomeBook.Backend.Data.Entities.Recipe", "Recipe")
+ .WithMany()
+ .HasForeignKey("RecipeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Ingredient");
+
+ b.Navigation("Recipe");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.SavingGoal", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.UserPreference", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Ingredient", b =>
+ {
+ b.Navigation("RecipeIngredients");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/source/HomeBook.Backend.Data.Mysql/Migrations/20251208213633_AddAdditionalDataToRecipe.cs b/source/HomeBook.Backend.Data.Mysql/Migrations/20251208213633_AddAdditionalDataToRecipe.cs
new file mode 100644
index 00000000..5963b03c
--- /dev/null
+++ b/source/HomeBook.Backend.Data.Mysql/Migrations/20251208213633_AddAdditionalDataToRecipe.cs
@@ -0,0 +1,68 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace HomeBook.Backend.Data.Mysql.Migrations
+{
+ ///
+ public partial class AddAdditionalDataToRecipe : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.RenameColumn(
+ name: "DurationMinutes",
+ table: "Recipes",
+ newName: "DurationWorkingMinutes");
+
+ migrationBuilder.AddColumn(
+ name: "Comments",
+ table: "Recipes",
+ type: "longtext",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "DurationCookingMinutes",
+ table: "Recipes",
+ type: "int",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "DurationRestingMinutes",
+ table: "Recipes",
+ type: "int",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "Source",
+ table: "Recipes",
+ type: "longtext",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Comments",
+ table: "Recipes");
+
+ migrationBuilder.DropColumn(
+ name: "DurationCookingMinutes",
+ table: "Recipes");
+
+ migrationBuilder.DropColumn(
+ name: "DurationRestingMinutes",
+ table: "Recipes");
+
+ migrationBuilder.DropColumn(
+ name: "Source",
+ table: "Recipes");
+
+ migrationBuilder.RenameColumn(
+ name: "DurationWorkingMinutes",
+ table: "Recipes",
+ newName: "DurationMinutes");
+ }
+ }
+}
diff --git a/source/HomeBook.Backend.Data.Mysql/Migrations/20251208232927_AddRecipeOptimizations.Designer.cs b/source/HomeBook.Backend.Data.Mysql/Migrations/20251208232927_AddRecipeOptimizations.Designer.cs
new file mode 100644
index 00000000..69590db4
--- /dev/null
+++ b/source/HomeBook.Backend.Data.Mysql/Migrations/20251208232927_AddRecipeOptimizations.Designer.cs
@@ -0,0 +1,345 @@
+//
+using System;
+using HomeBook.Backend.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace HomeBook.Backend.Data.Mysql.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20251208232927_AddRecipeOptimizations")]
+ partial class AddRecipeOptimizations
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Configuration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.ToTable("Configurations");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("CaloriesKcal")
+ .HasColumnType("int");
+
+ b.Property("Comments")
+ .HasColumnType("longtext");
+
+ b.Property("Description")
+ .HasColumnType("longtext");
+
+ b.Property("DurationCookingMinutes")
+ .HasColumnType("int");
+
+ b.Property("DurationRestingMinutes")
+ .HasColumnType("int");
+
+ b.Property("DurationWorkingMinutes")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Servings")
+ .HasColumnType("int");
+
+ b.Property("Source")
+ .HasColumnType("longtext");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Recipes");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe2RecipeIngredient", b =>
+ {
+ b.Property("RecipeId")
+ .HasColumnType("char(36)");
+
+ b.Property("IngredientId")
+ .HasColumnType("char(36)");
+
+ b.Property("Quantity")
+ .HasColumnType("double");
+
+ b.Property("RecipeIngredientId")
+ .HasColumnType("char(36)");
+
+ b.Property("Unit")
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.HasKey("RecipeId", "IngredientId");
+
+ b.HasIndex("RecipeIngredientId");
+
+ b.ToTable("Recipe2RecipeIngredient");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeIngredient", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique();
+
+ b.ToTable("RecipeIngredients");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeStep", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("RecipeId")
+ .HasColumnType("char(36)");
+
+ b.Property("TimerDurationInSeconds")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RecipeId");
+
+ b.ToTable("RecipeSteps");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.SavingGoal", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(7)
+ .HasColumnType("varchar(7)");
+
+ b.Property("CurrentAmount")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Icon")
+ .HasColumnType("longtext");
+
+ b.Property("InterestRate")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("InterestRateOption")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MonthlyPayment")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("TargetAmount")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("TargetDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("SavingGoals");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Created")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime(6)");
+
+ b.Property("Disabled")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IsAdmin")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("varchar(512)");
+
+ b.Property("PasswordHashType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.UserPreference", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.Property("Key")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("UserId", "Key");
+
+ b.ToTable("UserPreferences");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe2RecipeIngredient", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.Recipe", "Recipe")
+ .WithMany("Recipe2RecipeIngredient")
+ .HasForeignKey("RecipeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("HomeBook.Backend.Data.Entities.RecipeIngredient", "RecipeIngredient")
+ .WithMany("Recipe2RecipeIngredients")
+ .HasForeignKey("RecipeIngredientId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Recipe");
+
+ b.Navigation("RecipeIngredient");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeStep", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.Recipe", "Recipe")
+ .WithMany("Steps")
+ .HasForeignKey("RecipeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Recipe");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.SavingGoal", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.UserPreference", b =>
+ {
+ b.HasOne("HomeBook.Backend.Data.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe", b =>
+ {
+ b.Navigation("Recipe2RecipeIngredient");
+
+ b.Navigation("Steps");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeIngredient", b =>
+ {
+ b.Navigation("Recipe2RecipeIngredients");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/source/HomeBook.Backend.Data.Mysql/Migrations/20251208232927_AddRecipeOptimizations.cs b/source/HomeBook.Backend.Data.Mysql/Migrations/20251208232927_AddRecipeOptimizations.cs
new file mode 100644
index 00000000..386d2310
--- /dev/null
+++ b/source/HomeBook.Backend.Data.Mysql/Migrations/20251208232927_AddRecipeOptimizations.cs
@@ -0,0 +1,235 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace HomeBook.Backend.Data.Mysql.Migrations
+{
+ ///
+ public partial class AddRecipeOptimizations : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "FK_RecipeIngredients_Ingredients_IngredientId",
+ table: "RecipeIngredients");
+
+ migrationBuilder.DropForeignKey(
+ name: "FK_RecipeIngredients_Recipes_RecipeId",
+ table: "RecipeIngredients");
+
+ migrationBuilder.DropTable(
+ name: "Ingredients");
+
+ migrationBuilder.DropPrimaryKey(
+ name: "PK_RecipeIngredients",
+ table: "RecipeIngredients");
+
+ migrationBuilder.DropIndex(
+ name: "IX_RecipeIngredients_IngredientId",
+ table: "RecipeIngredients");
+
+ migrationBuilder.DropColumn(
+ name: "RecipeId",
+ table: "RecipeIngredients");
+
+ migrationBuilder.DropColumn(
+ name: "Quantity",
+ table: "RecipeIngredients");
+
+ migrationBuilder.DropColumn(
+ name: "Unit",
+ table: "RecipeIngredients");
+
+ migrationBuilder.RenameColumn(
+ name: "IngredientId",
+ table: "RecipeIngredients",
+ newName: "Id");
+
+ migrationBuilder.AddColumn(
+ name: "Name",
+ table: "RecipeIngredients",
+ type: "varchar(100)",
+ maxLength: 100,
+ nullable: false,
+ defaultValue: "");
+
+ migrationBuilder.AddColumn(
+ name: "NormalizedName",
+ table: "RecipeIngredients",
+ type: "varchar(100)",
+ maxLength: 100,
+ nullable: false,
+ defaultValue: "");
+
+ migrationBuilder.AddPrimaryKey(
+ name: "PK_RecipeIngredients",
+ table: "RecipeIngredients",
+ column: "Id");
+
+ migrationBuilder.CreateTable(
+ name: "Recipe2RecipeIngredient",
+ columns: table => new
+ {
+ RecipeId = table.Column(type: "char(36)", nullable: false),
+ IngredientId = table.Column(type: "char(36)", nullable: false),
+ RecipeIngredientId = table.Column(type: "char(36)", nullable: false),
+ Quantity = table.Column(type: "double", nullable: true),
+ Unit = table.Column(type: "varchar(20)", maxLength: 20, nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Recipe2RecipeIngredient", x => new { x.RecipeId, x.IngredientId });
+ table.ForeignKey(
+ name: "FK_Recipe2RecipeIngredient_RecipeIngredients_RecipeIngredientId",
+ column: x => x.RecipeIngredientId,
+ principalTable: "RecipeIngredients",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_Recipe2RecipeIngredient_Recipes_RecipeId",
+ column: x => x.RecipeId,
+ principalTable: "Recipes",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ })
+ .Annotation("MySQL:Charset", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "RecipeSteps",
+ columns: table => new
+ {
+ Id = table.Column(type: "char(36)", nullable: false),
+ RecipeId = table.Column(type: "char(36)", nullable: false),
+ Description = table.Column(type: "varchar(500)", maxLength: 500, nullable: false),
+ TimerDurationInSeconds = table.Column(type: "int", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_RecipeSteps", x => x.Id);
+ table.ForeignKey(
+ name: "FK_RecipeSteps_Recipes_RecipeId",
+ column: x => x.RecipeId,
+ principalTable: "Recipes",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ })
+ .Annotation("MySQL:Charset", "utf8mb4");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_RecipeIngredients_NormalizedName",
+ table: "RecipeIngredients",
+ column: "NormalizedName",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Recipe2RecipeIngredient_RecipeIngredientId",
+ table: "Recipe2RecipeIngredient",
+ column: "RecipeIngredientId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_RecipeSteps_RecipeId",
+ table: "RecipeSteps",
+ column: "RecipeId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Recipe2RecipeIngredient");
+
+ migrationBuilder.DropTable(
+ name: "RecipeSteps");
+
+ migrationBuilder.DropPrimaryKey(
+ name: "PK_RecipeIngredients",
+ table: "RecipeIngredients");
+
+ migrationBuilder.DropIndex(
+ name: "IX_RecipeIngredients_NormalizedName",
+ table: "RecipeIngredients");
+
+ migrationBuilder.DropColumn(
+ name: "Name",
+ table: "RecipeIngredients");
+
+ migrationBuilder.DropColumn(
+ name: "NormalizedName",
+ table: "RecipeIngredients");
+
+ migrationBuilder.RenameColumn(
+ name: "Id",
+ table: "RecipeIngredients",
+ newName: "IngredientId");
+
+ migrationBuilder.AddColumn(
+ name: "RecipeId",
+ table: "RecipeIngredients",
+ type: "char(36)",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn(
+ name: "Quantity",
+ table: "RecipeIngredients",
+ type: "varchar(50)",
+ maxLength: 50,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "Unit",
+ table: "RecipeIngredients",
+ type: "varchar(20)",
+ maxLength: 20,
+ nullable: true);
+
+ migrationBuilder.AddPrimaryKey(
+ name: "PK_RecipeIngredients",
+ table: "RecipeIngredients",
+ columns: new[] { "RecipeId", "IngredientId" });
+
+ migrationBuilder.CreateTable(
+ name: "Ingredients",
+ columns: table => new
+ {
+ Id = table.Column(type: "char(36)", nullable: false),
+ Name = table.Column(type: "varchar(100)", maxLength: 100, nullable: false),
+ NormalizedName = table.Column(type: "varchar(100)", maxLength: 100, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Ingredients", x => x.Id);
+ })
+ .Annotation("MySQL:Charset", "utf8mb4");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_RecipeIngredients_IngredientId",
+ table: "RecipeIngredients",
+ column: "IngredientId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Ingredients_NormalizedName",
+ table: "Ingredients",
+ column: "NormalizedName",
+ unique: true);
+
+ migrationBuilder.AddForeignKey(
+ name: "FK_RecipeIngredients_Ingredients_IngredientId",
+ table: "RecipeIngredients",
+ column: "IngredientId",
+ principalTable: "Ingredients",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "FK_RecipeIngredients_Recipes_RecipeId",
+ table: "RecipeIngredients",
+ column: "RecipeId",
+ principalTable: "Recipes",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ }
+ }
+}
diff --git a/source/HomeBook.Backend.Data.Mysql/Migrations/20251209132340_AddRecipeStepPosition.Designer.cs b/source/HomeBook.Backend.Data.Mysql/Migrations/20251209132340_AddRecipeStepPosition.Designer.cs
new file mode 100644
index 00000000..472de9cc
--- /dev/null
+++ b/source/HomeBook.Backend.Data.Mysql/Migrations/20251209132340_AddRecipeStepPosition.Designer.cs
@@ -0,0 +1,342 @@
+//
+using System;
+using HomeBook.Backend.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace HomeBook.Backend.Data.Mysql.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20251209132340_AddRecipeStepPosition")]
+ partial class AddRecipeStepPosition
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Configuration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.ToTable("Configurations");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("CaloriesKcal")
+ .HasColumnType("int");
+
+ b.Property("Comments")
+ .HasColumnType("longtext");
+
+ b.Property("Description")
+ .HasColumnType("longtext");
+
+ b.Property("DurationCookingMinutes")
+ .HasColumnType("int");
+
+ b.Property("DurationRestingMinutes")
+ .HasColumnType("int");
+
+ b.Property("DurationWorkingMinutes")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Servings")
+ .HasColumnType("int");
+
+ b.Property("Source")
+ .HasColumnType("longtext");
+
+ b.Property("UserId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Recipes");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.Recipe2RecipeIngredient", b =>
+ {
+ b.Property("RecipeId")
+ .HasColumnType("char(36)");
+
+ b.Property("IngredientId")
+ .HasColumnType("char(36)");
+
+ b.Property("Quantity")
+ .HasColumnType("double");
+
+ b.Property("RecipeIngredientId")
+ .HasColumnType("char(36)");
+
+ b.Property("Unit")
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.HasKey("RecipeId", "IngredientId");
+
+ b.HasIndex("RecipeIngredientId");
+
+ b.ToTable("Recipe2RecipeIngredient");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeIngredient", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique();
+
+ b.ToTable("RecipeIngredients");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.RecipeStep", b =>
+ {
+ b.Property("RecipeId")
+ .HasColumnType("char(36)");
+
+ b.Property("Position")
+ .HasColumnType("int");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("TimerDurationInSeconds")
+ .HasColumnType("int");
+
+ b.HasKey("RecipeId", "Position");
+
+ b.ToTable("RecipeSteps");
+ });
+
+ modelBuilder.Entity("HomeBook.Backend.Data.Entities.SavingGoal", b =>
+ {
+ b.Property