#nullable enable using System.Globalization; using Baya.Application.Contracts.Search; using Baya.Application.Models.Common; using Baya.Application.Models.Search; using Baya.Domain.Entities.Search; using Microsoft.EntityFrameworkCore; namespace Baya.Infrastructure.Persistence.Services.Search; /// /// The MVP backend — the real, production search over the maintained /// nurse_search_index. It reads only is_searchable = 1 rows (an unverified, suspended, /// paused, or deactivated nurse/variant never surfaces), applies the category/city/district/gender/price /// filters and the rating sort, and paginates. Served from the covering search index; a later /// ElasticNurseSearch replaces this class behind the same interface with no caller changes. /// internal sealed class SqlNurseSearch(ApplicationDbContext db) : INurseSearch { public async Task> SearchAsync(NurseSearchCriteria criteria, CancellationToken cancellationToken) { var query = db.Set() .AsNoTracking() .Where(r => r.IsSearchable && r.ServiceCategoryId == criteria.ServiceCategoryId && r.CityId == criteria.CityId); // NULL-district = "whole city". A district search matches that district's rows PLUS the whole-city // (NULL) rows; a city-only search (no district) matches every row in the city, NULL or not. if (criteria.DistrictId is { } districtId) query = query.Where(r => r.DistrictId == districtId || r.DistrictId == null); if (!string.IsNullOrWhiteSpace(criteria.NurseGender)) query = query.Where(r => r.NurseGender == criteria.NurseGender); if (criteria.MinPrice is { } min) query = query.Where(r => r.Price >= min); if (criteria.MaxPrice is { } max) query = query.Where(r => r.Price <= max); if (!string.IsNullOrWhiteSpace(criteria.PriceUnit)) query = query.Where(r => r.PriceUnit == criteria.PriceUnit); var total = await query.CountAsync(cancellationToken); var rows = await query // Rating sort is the only MVP sort; the tiebreak on reviews then nurse_id keeps paging deterministic. .OrderByDescending(r => r.AverageRating) .ThenByDescending(r => r.TotalReviews) .ThenBy(r => r.NurseId) .ThenBy(r => r.VariantId) .Skip((criteria.Page - 1) * criteria.PageSize) .Take(criteria.PageSize) .Select(r => new Row( r.VariantId, r.NurseId, r.ServiceCategoryId, r.Price, r.PriceUnit, r.NurseGender, r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId, r.NurseName, r.AvatarUrl)) .ToListAsync(cancellationToken); // Format price to a digit string in memory (no long.ToString translation required in SQL). // DistanceKm is null: the covering index carries no coordinate, so distance is not derivable here. var items = rows.Select(r => new NurseSearchResultDto( r.VariantId, r.NurseId, r.ServiceCategoryId, r.Price.ToString(CultureInfo.InvariantCulture), r.PriceUnit, r.NurseGender, r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId, r.NurseName, r.AvatarUrl, null)).ToList(); return new PagedResult(items, total, criteria.Page, criteria.PageSize); } private sealed record Row( long VariantId, long NurseId, long ServiceCategoryId, long Price, string PriceUnit, string NurseGender, decimal AverageRating, int TotalReviews, int TotalCompletedBookings, long CityId, long? DistrictId, string NurseName, string AvatarUrl); }