Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/CookifyAPI/CookifyAPI.sln.DotSettings.user
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExceptionDispatchInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fbd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182_003FExceptionDispatchInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIMapper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F73b4dc600ac748c4af020e320e393f1549800_003F2c_003F9cad8efc_003FIMapper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUserManager_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1622b8f95d27187bc1fb39bac49bd64e928773df4fda6fcf6271680be54a2_003FUserManager_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUserManager_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F48be9ea6485d4b26b8d5ac570ec0bee12b710_003Fa9_003F4155b38d_003FUserManager_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
39 changes: 39 additions & 0 deletions backend/CookifyAPI/CookifyAPI/Controllers/SearchController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using CookifyAPI.Models.DTOs.Requests;
using CookifyAPI.Models.DTOs.Search;
using CookifyAPI.Services;
using Microsoft.AspNetCore.Mvc;

namespace CookifyAPI.Controllers;

[ApiController]
[Route("api/search")]
public class SearchController(
ISearchService searchService,
IRecipeService recipeService) : ControllerBase
{
[HttpGet("tags")]
public async Task<IActionResult> SearchTags([FromQuery] string? name, [FromQuery] int limit = 20)
{
if (string.IsNullOrWhiteSpace(name))
return Ok(Array.Empty<TagSearchDocument>()); // Пустой массив, если нет запроса

var results = await searchService.SearchTagsAsync(name, limit);
return Ok(results);
}

[HttpGet("ingredients")]
public async Task<IActionResult> SearchIngredients([FromQuery] string? name, [FromQuery] int limit = 20)
{
if (string.IsNullOrWhiteSpace(name))
return Ok(Array.Empty<IngredientSearchDocument>());
var results = await searchService.SearchIngredientsAsync(name, limit);
return Ok(results);
}

[HttpGet("recipes")]
public async Task<IActionResult> SearchRecipes([FromQuery] RecipeSearchRequest request)
{
var results = await recipeService.SearchRecipesDetailedAsync(request);
return Ok(results);
}
}
1 change: 1 addition & 0 deletions backend/CookifyAPI/CookifyAPI/CookifyAPI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PackageReference Include="CloudinaryDotNet" Version="1.28.0" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="MeiliSearch" Version="0.18.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.15" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.15" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.15" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
using System.Reflection;
using CookifyAPI.Services;
using Meilisearch;

namespace CookifyAPI.Extensions;

public static class ApplicationServiceExtensions
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<IAuthService, AuthService>();
Expand All @@ -14,7 +15,16 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped<IImageService, CloudinaryImageService>();
services.AddScoped<IEmailService, EmailService>();
services.AddScoped<IUserService, UserService>();


var meiliUrl = configuration["MeilisearchSettings:Url"];
var meiliKey = configuration["MeilisearchSettings:MasterKey"];

if (string.IsNullOrEmpty(meiliUrl) || string.IsNullOrEmpty(meiliKey))
throw new InvalidOperationException("Meilisearch Url or MasterKey is not configured.");

// Регистрируем клиент (Singleton, т.к. он держит HTTP-соединения открытыми)
services.AddSingleton(new MeilisearchClient(meiliUrl, meiliKey));
services.AddScoped<ISearchService, MeilisearchService>();

// Автоматический поиск профилей AutoMapper
//services.AddAutoMapper(Assembly.GetExecutingAssembly());
Expand Down
94 changes: 70 additions & 24 deletions backend/CookifyAPI/CookifyAPI/Extensions/MigrationExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,38 +1,84 @@
using CookifyAPI.Data;
using CookifyAPI.Models.DTOs.Search;
using CookifyAPI.Services;
using Microsoft.EntityFrameworkCore;

namespace CookifyAPI.Extensions;

public static class MigrationExtensions
{
public static void ApplyMigrations(this IApplicationBuilder app)
public static async Task ApplyMigrations(this IApplicationBuilder app)
{
using (var scope = app.ApplicationServices.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

// Попытка применить миграции несколько раз (защита от медленного старта БД)
int retries = 20;
while (retries > 0)
using var scope = app.ApplicationServices.CreateScope();
var services = scope.ServiceProvider;

var db = services.GetRequiredService<AppDbContext>();
var searchService = services.GetRequiredService<ISearchService>();

// Попытка применить миграции несколько раз (защита от медленного старта БД)
var retries = 20;
var dbReady = false;

while (retries > 0)
try
{
try
{
db.Database.Migrate();
Console.WriteLine("Database check/migration completed successfully.");
break;
}
catch (Exception ex)
db.Database.Migrate();
Console.WriteLine("Database check/migration completed successfully.");
dbReady = true;
break;
}
catch (Exception ex)
{
retries--;
if (retries == 0)
{
retries--;
if (retries == 0)
{
Console.WriteLine("CRITICAL ERROR: Database is not ready after multiple retries.");
throw; // Приложение упадет, и Docker его перезапустит
}
Console.WriteLine($"WARNING: Database is starting up... waiting. ({retries} attempts left)");
Thread.Sleep(10000);
Console.WriteLine("CRITICAL ERROR: Database is not ready after multiple retries.");
throw; // Приложение упадет, и Docker его перезапустит
}

Console.WriteLine($"WARNING: Database is starting up... waiting. ({retries} attempts left)");
Thread.Sleep(10000);
}

if (dbReady)
try
{
Console.WriteLine("Starting Meilisearch index synchronization...");
await searchService.SetupIndicesAsync();

// Синхронизируем Ингредиенты
var ingredients = await db.Ingredients
.AsNoTracking()
.Select(i => new IngredientSearchDocument(i.Id, i.Name, i.Calories100g, i.Protein100g, i.Fat100g, i.Carb100g))
.ToListAsync();

if (ingredients.Count != 0)
await searchService.IndexIngredientsAsync(ingredients);

// Синхронизируем Теги
var tags = await db.Tags
.AsNoTracking()
.Select(t => new TagSearchDocument(t.Id, t.Name))
.ToListAsync();

if (tags.Count != 0)
await searchService.IndexTagsAsync(tags);

// Синхронизируем Рецепты
var recipes = await db.Recipes
.AsNoTracking()
.Select(r => new RecipeSearchDocument(r.Id, r.Title))
.ToListAsync();

if (recipes.Count != 0)
await searchService.IndexRecipesAsync(recipes);

Console.WriteLine("Meilisearch synchronization completed successfully.");

}
catch (Exception ex)
{
Console.WriteLine($"ERROR: Failed to sync Meilisearch: {ex.Message}");
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Mvc;

namespace CookifyAPI.Models.DTOs.Requests;

public record RecipeSearchRequest(
string? Title,
int? MaxCookingTime,
float? MinCarb, float? MaxCarb,
float? MinProtein, float? MaxProtein,
float? MinFat, float? MaxFat,
float? MinCalories, float? MaxCalories,
int? Difficulty,
[FromQuery] int[]? TagIds,
[FromQuery] int[]? IngredientIds
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace CookifyAPI.Models.DTOs.Search;

public record IngredientSearchDocument(
int Id,
string Name,
float? Calories100g,
float? Protein100g,
float? Fat100g,
float? Carb100g
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CookifyAPI.Models.DTOs.Search;

public record RecipeSearchDocument(
int Id,
string Title
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CookifyAPI.Models.DTOs.Search;

public record TagSearchDocument(
int Id,
string Name
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace CookifyAPI.Models.Settings;

public record MeilisearchSettings
{
public string Url { get; init; } = string.Empty;
public string MasterKey { get; init; } = string.Empty;
}
4 changes: 2 additions & 2 deletions backend/CookifyAPI/CookifyAPI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
builder.Services.AddIdentityServices(builder.Configuration);
builder.Services.AddSwaggerServices();

builder.Services.AddApplicationServices();
builder.Services.AddApplicationServices(builder.Configuration);
builder.Services.AddWebServices();

var app = builder.Build();
Expand All @@ -22,7 +22,7 @@
});
}

app.ApplyMigrations();
await app.ApplyMigrations();

app.UseCors("AllowAll");
app.UseAuthentication();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using CookifyAPI.Models.DTOs.Search;
using Meilisearch;

namespace CookifyAPI.Services;

public class MeilisearchService(MeilisearchClient client) : ISearchService
{
private const string TagsIndex = "tags";
private const string IngredientsIndex = "ingredients";
private const string RecipesIndex = "recipes";

public async Task<IReadOnlyCollection<TagSearchDocument>> SearchTagsAsync(string query, int limit = 20)
{
var index = client.Index(TagsIndex);
var searchQuery = new SearchQuery { Limit = limit };

var result = await index.SearchAsync<TagSearchDocument>(query, searchQuery);
return result.Hits;
}

public async Task<IReadOnlyCollection<IngredientSearchDocument>> SearchIngredientsAsync(string query, int limit = 20)
{
var index = client.Index(IngredientsIndex);
var searchQuery = new SearchQuery { Limit = limit };

var result = await index.SearchAsync<IngredientSearchDocument>(query, searchQuery);
return result.Hits;
}

public async Task<int[]> SearchRecipeIdsAsync(string query, int limit = 30)
{
var index = client.Index(RecipesIndex);
var searchQuery = new SearchQuery { Limit = limit };

var result = await index.SearchAsync<RecipeSearchDocument>(query, searchQuery);
return result.Hits.Select(x => x.Id).ToArray();
}

public async Task IndexTagsAsync(IEnumerable<TagSearchDocument> tags)
{
var index = client.Index(TagsIndex);
await index.AddDocumentsAsync(tags);
}

public Task IndexIngredientsAsync(IEnumerable<IngredientSearchDocument> ingredients)
{
var index = client.Index(IngredientsIndex);
return index.AddDocumentsAsync(ingredients);
}

public async Task IndexRecipesAsync(IEnumerable<RecipeSearchDocument> recipes)
{
await client.Index("recipes").AddDocumentsAsync(recipes);
}

public async Task SetupIndicesAsync()
{
await client.Index(TagsIndex).UpdateSearchableAttributesAsync(new[] { "name" });
await client.Index(IngredientsIndex).UpdateSearchableAttributesAsync(new[] { "name" });
await client.Index(RecipesIndex).UpdateSearchableAttributesAsync(new[] { "title" });
}
}
Loading
Loading