ef15fd6247
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>
359 lines
14 KiB
C#
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);
|
|
}
|