Files
meezi/src/Meezi.API/Services/ReviewService.cs
T
soroush.asadi ef15fd6247 feat(api): .NET 10 multi-tenant REST API
Full backend implementation:
- Multi-tenant cafe/restaurant management (menus, orders, tables, staff)
- POS order flow with ZarinPal and Snappfood payment integration
- OTP authentication via Kavenegar SMS
- QR digital menu with public discover/finder endpoints
- Customer loyalty, coupons, CRM
- PostgreSQL via EF Core, Redis for caching/sessions
- Background jobs, webhook handlers
- Full migration history

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-27 21:33:48 +03:30

359 lines
14 KiB
C#

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<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
DiscoverFilterParams filters,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<CafeReviewDto>> 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<CafeReviewDto?> ReplyReviewAsync(string cafeId, string reviewId, string reply, CancellationToken cancellationToken = default);
Task<CafeReviewDto?> SetHiddenAsync(string cafeId, string reviewId, bool isHidden, CancellationToken cancellationToken = default);
Task<(CafeReviewDto? Data, string? ErrorCode, string? Message)> CreateReviewWithPhotosAsync(
string cafeId,
CreateCafeReviewRequest request,
IReadOnlyList<IFormFile> 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<IReadOnlyList<CafeDiscoverDto>> 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<IReadOnlyList<CafeReviewDto>> 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<IFormFile> 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<CafeReviewDto?> 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<CafeReviewDto?> 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<CafeBadgePublicDto> 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<WorkingHoursSchedule>(json, _jsonOpts); }
catch { return null; }
}
private static IReadOnlyList<string> DeserializeGallery(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
try
{
return JsonSerializer.Deserialize<List<string>>(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);
}