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,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"];
|
||||
}
|
||||
@@ -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:00–02: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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user