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:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
@@ -0,0 +1,36 @@
namespace Meezi.Core.Discover;
public record CafeBadgeDefinition(string Key, string LabelFa, string LabelEn, string LabelAr, string Icon);
public static class CafeBadgeCatalog
{
public static readonly IReadOnlyList<CafeBadgeDefinition> All =
[
new("verified_partner", "شریک تأییدشده", "Verified partner", "شريك موثّق", "✓"),
new("award_winner", "برنده جایزه", "Award winner", "فائز بجمة", "🏆"),
new("roastery", "رستری تخصصی", "Specialty roastery", "محمصة متخصصة", "☕"),
new("eco_friendly", "دوستدار محیط زیست", "Eco-friendly", "صديق للبيئة", "🌿"),
new("women_owned", "مدیریت زنان", "Women-owned", "إدارة نسائية", "👩"),
new("pet_friendly", "مجاز حیوان خانگی", "Pet-friendly", "يسمح بالحيوانات", "🐾"),
];
private static readonly Dictionary<string, CafeBadgeDefinition> ByKey =
All.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase);
public static bool IsValidKey(string? key) =>
!string.IsNullOrWhiteSpace(key) && ByKey.ContainsKey(key.Trim());
public static CafeBadgeDefinition? Resolve(string key) =>
ByKey.TryGetValue(key.Trim(), out var def) ? def : null;
public static IReadOnlyList<string> NormalizeKeys(IEnumerable<string>? keys)
{
if (keys is null) return [];
return keys
.Select(k => k.Trim())
.Where(IsValidKey)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(8)
.ToList();
}
}
@@ -0,0 +1,76 @@
namespace Meezi.Core.Discover;
/// <summary>
/// Café attributes for discover / AI matching (stored as JSON on <see cref="Entities.Cafe.DiscoverProfileJson"/>).
/// </summary>
public class CafeDiscoverProfile
{
/// <summary>Decor / concept themes (multi): modern, vintage, plants-heavy, …</summary>
public List<string> Themes { get; set; } = [];
/// <summary>Physical scale: tiny, cozy, medium, large, spacious.</summary>
public string? Size { get; set; }
/// <summary>Floor count: one, two, three, multi.</summary>
public string? Floors { get; set; }
/// <summary>Atmosphere tags (multi).</summary>
public List<string> Vibes { get; set; } = [];
/// <summary>Good for: date, family, friends, finding_someone, etc. (multi).</summary>
public List<string> Occasions { get; set; } = [];
/// <summary>indoor, outdoor, plants, terrace, … (multi).</summary>
public List<string> SpaceFeatures { get; set; } = [];
/// <summary>quiet, moderate, lively.</summary>
public string? NoiseLevel { get; set; }
/// <summary>budget, mid, premium.</summary>
public string? PriceTier { get; set; }
}
public static class CafeDiscoverProfileKeys
{
public static readonly HashSet<string> Themes =
[
"modern", "minimal", "vintage", "industrial", "scandi", "persian_traditional",
"book_cafe", "roastery", "dessert_focus", "brunch", "late_night",
"plants_heavy", "instagrammable", "heritage", "luxury",
// extended
"specialty_coffee", "tea_house", "art_gallery", "sport_cafe", "gaming_cafe"
];
public static readonly HashSet<string> Sizes =
["tiny", "cozy", "medium", "large", "spacious"];
public static readonly HashSet<string> Floors =
["one", "two", "three", "multi"];
public static readonly HashSet<string> Vibes =
[
"quiet", "lively", "romantic", "cozy", "trendy", "traditional",
"artistic", "luxury", "casual", "study_friendly"
];
public static readonly HashSet<string> Occasions =
[
"date", "family", "friends", "finding_someone", "solo",
"business_meeting", "study_work", "celebration",
"quick_coffee", "breakfast", "brunch",
// extended
"after_dinner", "group_large"
];
public static readonly HashSet<string> SpaceFeatures =
[
"indoor", "outdoor", "terrace", "rooftop", "garden", "plants",
"wifi", "parking", "wheelchair", "kids_friendly", "pet_friendly",
"smoking_area", "live_music", "private_room", "counter_only",
// extended
"takeaway", "hookah", "board_games", "no_smoking", "prayer_room"
];
public static readonly HashSet<string> NoiseLevels = ["quiet", "moderate", "lively"];
public static readonly HashSet<string> PriceTiers = ["budget", "mid", "premium"];
}
+80
View File
@@ -0,0 +1,80 @@
namespace Meezi.Core.Discover;
/// <summary>Opening hours for a single day.</summary>
public class DaySchedule
{
public bool IsOpen { get; set; }
/// <summary>24-h HH:mm, e.g. "08:00"</summary>
public string? Open { get; set; }
/// <summary>24-h HH:mm, e.g. "23:30". If past midnight, still next-calendar-day open time.</summary>
public string? Close { get; set; }
}
/// <summary>
/// Full-week schedule. Iran week: Saturday = first day, Friday = last day.
/// Stored as JSON on <see cref="Entities.Cafe.WorkingHoursJson"/>.
/// </summary>
public class WorkingHoursSchedule
{
public DaySchedule? Sat { get; set; }
public DaySchedule? Sun { get; set; }
public DaySchedule? Mon { get; set; }
public DaySchedule? Tue { get; set; }
public DaySchedule? Wed { get; set; }
public DaySchedule? Thu { get; set; }
public DaySchedule? Fri { get; set; }
/// <summary>
/// Returns the <see cref="DaySchedule"/> for the given UTC day-of-week,
/// adjusted to Iran Standard Time (UTC+3:30).
/// </summary>
public DaySchedule? ForUtcNow()
{
// Iran Standard Time: UTC+03:30 (no DST)
var iranOffset = TimeSpan.FromMinutes(210);
var iranNow = DateTimeOffset.UtcNow.ToOffset(iranOffset);
return iranNow.DayOfWeek switch
{
DayOfWeek.Saturday => Sat,
DayOfWeek.Sunday => Sun,
DayOfWeek.Monday => Mon,
DayOfWeek.Tuesday => Tue,
DayOfWeek.Wednesday => Wed,
DayOfWeek.Thursday => Thu,
DayOfWeek.Friday => Fri,
_ => null
};
}
/// <summary>Returns true when the cafe is currently open based on Iran time.</summary>
public bool IsOpenNow()
{
var day = ForUtcNow();
if (day is null || !day.IsOpen) return false;
if (string.IsNullOrEmpty(day.Open) || string.IsNullOrEmpty(day.Close)) return true; // open all day
var iranOffset = TimeSpan.FromMinutes(210);
var iranNow = DateTimeOffset.UtcNow.ToOffset(iranOffset);
var nowMinutes = iranNow.Hour * 60 + iranNow.Minute;
if (!TryParseMinutes(day.Open, out var openMin) || !TryParseMinutes(day.Close, out var closeMin))
return true;
// Handle spans crossing midnight (e.g. 22:0002:00)
if (closeMin <= openMin)
return nowMinutes >= openMin || nowMinutes < closeMin;
return nowMinutes >= openMin && nowMinutes < closeMin;
}
private static bool TryParseMinutes(string? hhmm, out int minutes)
{
minutes = 0;
if (string.IsNullOrEmpty(hhmm)) return false;
var parts = hhmm.Split(':');
if (parts.Length != 2) return false;
if (!int.TryParse(parts[0], out var h) || !int.TryParse(parts[1], out var m)) return false;
minutes = h * 60 + m;
return true;
}
}