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>
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user