using System.Text.Json; using Meezi.API.Models.Public; using Meezi.API.Security; using Meezi.Core.Discover; using Meezi.Core.Entities; using Meezi.Core.Utilities; using Meezi.Infrastructure.Data; using Meezi.Core.Enums; using Meezi.Infrastructure.Discover; using Microsoft.EntityFrameworkCore; namespace Meezi.API.Services; public interface IReviewService { Task> DiscoverAsync( DiscoverFilterParams filters, CancellationToken cancellationToken = default); Task> GetReviewsAsync( string cafeId, int page, int pageSize, bool publicOnly = true, CancellationToken cancellationToken = default); Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewAsync( string cafeId, CreateCafeReviewRequest request, CancellationToken cancellationToken = default); Task ReplyReviewAsync(string cafeId, string reviewId, string reply, CancellationToken cancellationToken = default); Task SetHiddenAsync(string cafeId, string reviewId, bool isHidden, CancellationToken cancellationToken = default); Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewWithPhotosAsync( string cafeId, CreateCafeReviewRequest request, IReadOnlyList photos, CancellationToken cancellationToken = default); Task<(double Average, int Count)> GetRatingSummaryAsync(string cafeId, CancellationToken cancellationToken = default); } public class ReviewService : IReviewService { private const int MaxReviewPhotos = 3; private readonly AppDbContext _db; private readonly IAbuseProtectionService _abuse; private readonly IHttpContextAccessor _http; private readonly IMediaStorageService _media; public ReviewService( AppDbContext db, IAbuseProtectionService abuse, IHttpContextAccessor http, IMediaStorageService media) { _db = db; _abuse = abuse; _http = http; _media = media; } public async Task> DiscoverAsync( DiscoverFilterParams filters, CancellationToken cancellationToken = default) { var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null); if (!string.IsNullOrWhiteSpace(filters.City)) query = query.Where(c => c.City != null && c.City.Contains(filters.City)); if (!string.IsNullOrWhiteSpace(filters.Q)) { var q = filters.Q.Trim(); var qNorm = PersianSearchNormalizer.Normalize(q); var pattern = $"%{q}%"; var patternNorm = qNorm.Length > 0 && !string.Equals(qNorm, q, StringComparison.Ordinal) ? $"%{qNorm}%" : null; query = query.Where(c => EF.Functions.ILike(c.Name, pattern) || EF.Functions.ILike(c.Slug, pattern) || (c.Description != null && EF.Functions.ILike(c.Description, pattern)) || (c.Address != null && EF.Functions.ILike(c.Address, pattern)) || (c.City != null && EF.Functions.ILike(c.City, pattern)) || (c.NameAr != null && EF.Functions.ILike(c.NameAr, pattern)) || (patternNorm != null && ( EF.Functions.ILike(c.Name, patternNorm) || (c.Description != null && EF.Functions.ILike(c.Description, patternNorm)) || (c.Address != null && EF.Functions.ILike(c.Address, patternNorm)))) || _db.MenuItems.Any(m => m.CafeId == c.Id && m.DeletedAt == null && (EF.Functions.ILike(m.Name, pattern) || (patternNorm != null && EF.Functions.ILike(m.Name, patternNorm))))); } var cafes = await query.ToListAsync(cancellationToken); var cafeIds = cafes.Select(c => c.Id).ToList(); var ratings = await _db.CafeReviews .Where(r => cafeIds.Contains(r.CafeId) && !r.IsHidden) .GroupBy(r => r.CafeId) .Select(g => new { CafeId = g.Key, Avg = g.Average(x => x.Rating), Count = g.Count() }) .ToListAsync(cancellationToken); var ratingMap = ratings.ToDictionary(x => x.CafeId, x => (x.Avg, x.Count)); // Determine whether this is a free-text NLP search or a pure chip-filter search bool hasTextQuery = !string.IsNullOrWhiteSpace(filters.Q); var result = cafes .Select(c => { ratingMap.TryGetValue(c.Id, out var r); var count = r.Count; var avg = count > 0 ? r.Avg : 0.0; var profile = CafeDiscoverProfileSerializer.Deserialize(c.DiscoverProfileJson); var hours = DeserializeHours(c.WorkingHoursJson); var gallery = DeserializeGallery(c.GalleryJson); var badges = MapBadges(c); // openNow filter — skip cafes that are provably closed if (filters.OpenNow && hours is not null && !hours.IsOpenNow()) return default; double score; if (hasTextQuery) { // Soft scoring: partial matches surface instead of being hidden score = DiscoverProfileMatcher.Score(profile, filters); if (filters.RequireProfile && !DiscoverProfileMatcher.HasMeaningfulProfile(profile) && score < DiscoverProfileMatcher.MinScoreThreshold) return default; if (score < DiscoverProfileMatcher.MinScoreThreshold) return default; } else { // Hard AND match for chip-only searches (backward compatible) if (!DiscoverProfileMatcher.Matches(profile, filters)) return default; score = DiscoverProfileMatcher.Score(profile, filters); } bool isOpenNow = hours?.IsOpenNow() ?? false; var dto = new CafeDiscoverDto( c.Id, c.Name, c.Slug, c.City, c.Address, c.LogoUrl, c.CoverImageUrl, c.IsVerified, Math.Round(avg, 1), count, CafeDiscoverProfileMapping.ToDto(profile), badges, gallery, isOpenNow, c.InstagramHandle, c.WebsiteUrl, score); return (dto, score, (object?)dto); }) .Where(x => x.Item3 is not null) .Select(x => x.dto) .ToList(); if (filters.MinRating.HasValue) result = result.Where(c => c.AverageRating >= filters.MinRating.Value).ToList(); result = (filters.Sort?.ToLowerInvariant()) switch { "rating" => result.OrderByDescending(c => c.AverageRating).ThenByDescending(c => c.ReviewCount).ToList(), "reviews" => result.OrderByDescending(c => c.ReviewCount).ToList(), "score" => result.OrderByDescending(c => c.RelevanceScore).ThenByDescending(c => c.AverageRating).ToList(), _ => hasTextQuery ? result.OrderByDescending(c => c.RelevanceScore).ThenByDescending(c => c.AverageRating).ToList() : result.OrderBy(c => c.Name).ToList() }; return result; } public async Task> GetReviewsAsync( string cafeId, int page, int pageSize, bool publicOnly = true, CancellationToken cancellationToken = default) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 50); var reviews = await _db.CafeReviews .Include(r => r.Photos) .Where(r => r.CafeId == cafeId && (!publicOnly || !r.IsHidden)) .OrderByDescending(r => r.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); return reviews.Select(r => ToDto(r, publicView: true)).ToList(); } public async Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewAsync( string cafeId, CreateCafeReviewRequest request, CancellationToken cancellationToken = default) { var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId && c.IsVerified, cancellationToken); if (cafe is null) return (null, "NOT_FOUND", "Cafe not found."); var ctx = _http.HttpContext; if (ctx is not null) { var availability = PublicCafeGuard.EnsureAcceptingPublicTraffic(cafe); if (!availability.Ok) return (null, availability.ErrorCode, availability.Message); var ip = ClientIpResolver.GetClientIp(ctx); var writeCheck = await _abuse.CheckPublicWriteByIpAsync(ip, cancellationToken); if (!writeCheck.Allowed) return (null, writeCheck.ErrorCode, writeCheck.Message); var captcha = await _abuse.VerifyCaptchaAsync(request.CaptchaToken, cancellationToken); if (!captcha.Ok) return (null, captcha.ErrorCode, captcha.Message); } var entity = new CafeReview { CafeId = cafeId, AuthorName = request.AuthorName.Trim(), AuthorPhone = request.AuthorPhone?.Trim(), Rating = request.Rating, Comment = request.Comment?.Trim() }; _db.CafeReviews.Add(entity); await _db.SaveChangesAsync(cancellationToken); return (ToDto(entity, publicView: true), null, null); } public async Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewWithPhotosAsync( string cafeId, CreateCafeReviewRequest request, IReadOnlyList photos, CancellationToken cancellationToken = default) { var baseResult = await CreateReviewAsync(cafeId, request, cancellationToken); if (baseResult.Data is null) return baseResult; var files = photos?.Where(f => f.Length > 0).Take(MaxReviewPhotos).ToList() ?? []; if (files.Count == 0) return baseResult; var review = await _db.CafeReviews .Include(r => r.Photos) .FirstAsync(r => r.Id == baseResult.Data.Id, cancellationToken); var sort = 0; foreach (var file in files) { var url = await _media.SaveReviewPhotoAsync(cafeId, file, cancellationToken); if (url is null) continue; review.Photos.Add(new CafeReviewPhoto { ReviewId = review.Id, Url = url, SortOrder = sort++ }); } await _db.SaveChangesAsync(cancellationToken); return (ToDto(review, publicView: true), null, null); } public async Task ReplyReviewAsync( string cafeId, string reviewId, string reply, CancellationToken cancellationToken = default) { var entity = await _db.CafeReviews .FirstOrDefaultAsync(r => r.Id == reviewId && r.CafeId == cafeId, cancellationToken); if (entity is null) return null; entity.OwnerReply = reply.Trim(); entity.OwnerRepliedAt = DateTime.UtcNow; await _db.SaveChangesAsync(cancellationToken); return ToDto(entity, publicView: false); } public async Task SetHiddenAsync( string cafeId, string reviewId, bool isHidden, CancellationToken cancellationToken = default) { var entity = await _db.CafeReviews .Include(r => r.Photos) .FirstOrDefaultAsync(r => r.Id == reviewId && r.CafeId == cafeId, cancellationToken); if (entity is null) return null; entity.IsHidden = isHidden; await _db.SaveChangesAsync(cancellationToken); return ToDto(entity, publicView: false); } public async Task<(double Average, int Count)> GetRatingSummaryAsync(string cafeId, CancellationToken cancellationToken = default) { var reviews = await _db.CafeReviews .Where(r => r.CafeId == cafeId && !r.IsHidden) .ToListAsync(cancellationToken); if (reviews.Count == 0) return (0, 0); return (Math.Round(reviews.Average(r => r.Rating), 1), reviews.Count); } private static IReadOnlyList MapBadges(Cafe c) => DiscoverBadgeMapping.ToDtos(c) .Select(b => new CafeBadgePublicDto(b.Key, b.Label, b.Icon)) .ToList(); private static readonly JsonSerializerOptions _jsonOpts = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }; private static WorkingHoursSchedule? DeserializeHours(string? json) { if (string.IsNullOrWhiteSpace(json)) return null; try { return JsonSerializer.Deserialize(json, _jsonOpts); } catch { return null; } } private static IReadOnlyList DeserializeGallery(string? json) { if (string.IsNullOrWhiteSpace(json)) return []; try { return JsonSerializer.Deserialize>(json, _jsonOpts) ?? []; } catch { return []; } } private static CafeReviewDto ToDto(CafeReview r, bool publicView) => new( r.Id, r.AuthorName, r.Rating, r.Comment, r.OwnerReply, r.CreatedAt, r.Photos.OrderBy(p => p.SortOrder).Select(p => p.Url).ToList(), publicView ? false : r.IsHidden); }