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
+14
View File
@@ -0,0 +1,14 @@
{
"version": 1,
"palettes": [
"meezi-green", "ocean-blue", "royal-purple", "sunset-orange", "rose-blush",
"charcoal-gold", "espresso", "forest", "midnight", "coral", "gold-luxury",
"mint-fresh", "wine-bar", "slate-modern", "cherry", "teal-wave", "sand-cafe"
],
"panelStyles": ["flat", "modern", "glass", "minimal", "bold", "soft", "elevated", "outline"],
"menuStyles": ["cards", "compact", "grid", "list", "magazine", "classic"],
"menuTextures": ["none", "paper", "linen", "dots", "grid", "marble", "wood", "warm"],
"densities": ["compact", "comfortable", "spacious"],
"radius": ["none", "sm", "md", "lg", "full"],
"customColorKeys": ["primary", "secondary", "accent", "background", "surface", "text", "textMuted", "destructive", "success"]
}
+46
View File
@@ -0,0 +1,46 @@
{
"version": 2,
"styles": ["flat", "modern", "real", "minimal", "outline", "soft", "bold", "gradient", "pastel", "duotone"],
"presets": [
{ "id": "drinks-hot", "kind": "drink" },
{ "id": "drinks-cold", "kind": "drink" },
{ "id": "drinks-tea", "kind": "drink" },
{ "id": "drinks-juice", "kind": "drink" },
{ "id": "drinks-milkshake", "kind": "drink" },
{ "id": "drinks-alcohol", "kind": "drink" },
{ "id": "drinks-beer", "kind": "drink" },
{ "id": "breakfast", "kind": "food" },
{ "id": "food-mains", "kind": "food" },
{ "id": "food-fastfood", "kind": "food" },
{ "id": "food-rice", "kind": "food" },
{ "id": "pasta-pizza", "kind": "food" },
{ "id": "dessert", "kind": "food" },
{ "id": "ice-cream", "kind": "food" },
{ "id": "bakery", "kind": "food" },
{ "id": "salad", "kind": "food" },
{ "id": "grill", "kind": "food" },
{ "id": "seafood", "kind": "food" },
{ "id": "snacks", "kind": "food" },
{ "id": "snacks-sweet", "kind": "food" },
{ "id": "appetizers", "kind": "food" },
{ "id": "vegan", "kind": "food" },
{ "id": "fruits", "kind": "food" },
{ "id": "specials", "kind": "food" },
{ "id": "chef-special", "kind": "food" },
{ "id": "generic", "kind": "food" }
],
"emojiGroups": [
"hotDrinks",
"coldDrinks",
"breakfast",
"mains",
"pastaPizza",
"desserts",
"salads",
"seafoodGrill",
"snacks",
"vegan",
"specials",
"general"
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"version": 1,
"environment": "development",
"cafe": {
"id": "cafe_demo_001",
"slug": "demo-cafe",
"name": "کافه دمو"
},
"branch": {
"id": "branch_demo_main",
"name": "شعبه اصلی"
},
"auth": {
"note": "در محیط Development با OTP توسعه وارد شوید (کد در لاگ API).",
"otpDevBypass": true
},
"systemAdmin": {
"phone": "09120000001",
"note": "ورود مدیر سامانه: /fa/admin/login — OTP در لاگ API"
},
"employees": [
{ "id": "emp_demo_owner", "name": "مدیر دمو", "phone": "09121234567", "role": "Owner" },
{ "id": "emp_demo_manager", "name": "مدیر شعبه", "phone": "09121111111", "role": "Manager" },
{ "id": "emp_demo_cashier", "name": "صندوقدار", "phone": "09122222222", "role": "Cashier" },
{ "id": "emp_demo_waiter", "name": "گارسون", "phone": "09123333333", "role": "Waiter" },
{ "id": "emp_demo_waiter2", "name": "گارسون ۲", "phone": "09124444444", "role": "Waiter" },
{ "id": "emp_demo_chef", "name": "آشپز", "phone": "09125555555", "role": "Chef" },
{ "id": "emp_demo_delivery", "name": "پیک", "phone": "09126666666", "role": "Delivery" }
],
"publicQr": {
"tableQr": "demo_table_01",
"discoverSlug": "demo-cafe"
}
}
+644
View File
@@ -0,0 +1,644 @@
{
"version": 1,
"cafeId": "cafe_demo_001",
"categories": [
{
"Id": "cat_demo_drinks",
"Name": "\u0646\u0648\u0634\u06CC\u062F\u0646\u06CC \u06AF\u0631\u0645",
"NameEn": "Hot drinks",
"NameAr": "\u0645\u0634\u0631\u0648\u0628\u0627\u062A \u0633\u0627\u062E\u0646\u0629",
"SortOrder": 1,
"Icon": null,
"IconPresetId": "drinks-hot",
"IconStyle": "flat"
},
{
"Id": "cat_demo_cold",
"Name": "\u0646\u0648\u0634\u06CC\u062F\u0646\u06CC \u0633\u0631\u062F",
"NameEn": "Cold drinks",
"NameAr": "\u0645\u0634\u0631\u0648\u0628\u0627\u062A \u0628\u0627\u0631\u062F\u0629",
"SortOrder": 2,
"Icon": null,
"IconPresetId": "drinks-cold",
"IconStyle": "flat"
},
{
"Id": "cat_demo_breakfast",
"Name": "\u0635\u0628\u062D\u0627\u0646\u0647",
"NameEn": "Breakfast",
"NameAr": "\u0641\u0637\u0648\u0631",
"SortOrder": 3,
"Icon": null,
"IconPresetId": "breakfast",
"IconStyle": "flat"
},
{
"Id": "cat_demo_food",
"Name": "\u063A\u0630\u0627 \u0648 \u067E\u06CC\u0634\u200C\u063A\u0630\u0627",
"NameEn": "Food \u0026 snacks",
"NameAr": "\u0637\u0639\u0627\u0645",
"SortOrder": 4,
"Icon": null,
"IconPresetId": "food-mains",
"IconStyle": "flat"
},
{
"Id": "cat_demo_pasta",
"Name": "\u067E\u0627\u0633\u062A\u0627 \u0648 \u067E\u06CC\u062A\u0632\u0627",
"NameEn": "Pasta \u0026 pizza",
"NameAr": "\u0645\u0639\u0643\u0631\u0648\u0646\u0629 \u0648\u0628\u064A\u062A\u0632\u0627",
"SortOrder": 5,
"Icon": null,
"IconPresetId": "pasta-pizza",
"IconStyle": "flat"
},
{
"Id": "cat_demo_dessert",
"Name": "\u062F\u0633\u0631",
"NameEn": "Desserts",
"NameAr": "\u062D\u0644\u0648\u064A\u0627\u062A",
"SortOrder": 6,
"Icon": null,
"IconPresetId": "dessert",
"IconStyle": "flat"
}
],
"items": [
{
"Id": "item_demo_espresso",
"CategoryId": "cat_demo_drinks",
"Name": "\u0627\u0633\u067E\u0631\u0633\u0648",
"NameEn": "Espresso",
"NameAr": "\u0625\u0633\u0628\u0631\u064A\u0633\u0648",
"Description": "\u062F\u0648\u0628\u0644 \u06CC\u0627 \u0633\u06CC\u0646\u06AF\u0644",
"priceToman": 65000,
"DiscountPercent": 0,
"Food101Class": "espresso",
"imageUrl": "https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=600\u0026auto=format\u0026fit=crop"
},
{
"Id": "item_demo_americano",
"CategoryId": "cat_demo_drinks",
"Name": "\u0622\u0645\u0631\u06CC\u06A9\u0627\u0646\u0648",
"NameEn": "Americano",
"NameAr": "\u0623\u0645\u0631\u064A\u0643\u0627\u0646\u0648",
"Description": null,
"priceToman": 75000,
"DiscountPercent": 0,
"Food101Class": "cappuccino",
"imageUrl": "https://images.unsplash.com/photo-1572442388796-11668a67e3d0?w=600"
},
{
"Id": "item_demo_latte",
"CategoryId": "cat_demo_drinks",
"Name": "\u0644\u0627\u062A\u0647",
"NameEn": "Latte",
"NameAr": "\u0644\u0627\u062A\u064A\u0647",
"Description": "\u0634\u06CC\u0631 \u0628\u062E\u0627\u0631 \u06AF\u0631\u0641\u062A\u0647",
"priceToman": 120000,
"DiscountPercent": 0,
"Food101Class": "latte",
"imageUrl": "https://images.unsplash.com/photo-1461023058943-07fcbe16d735?w=600"
},
{
"Id": "item_demo_cappuccino",
"CategoryId": "cat_demo_drinks",
"Name": "\u06A9\u0627\u067E\u0648\u0686\u06CC\u0646\u0648",
"NameEn": "Cappuccino",
"NameAr": "\u0643\u0627\u0628\u062A\u0634\u064A\u0646\u0648",
"Description": null,
"priceToman": 110000,
"DiscountPercent": 10,
"Food101Class": "cappuccino",
"imageUrl": "https://images.unsplash.com/photo-1572442388796-11668a67e3d0?w=600"
},
{
"Id": "item_demo_mocha",
"CategoryId": "cat_demo_drinks",
"Name": "\u0645\u0648\u06A9\u0627",
"NameEn": "Mocha",
"NameAr": "\u0645\u0648\u0643\u0627",
"Description": "\u0634\u06A9\u0644\u0627\u062A \u0648 \u0642\u0647\u0648\u0647",
"priceToman": 135000,
"DiscountPercent": 0,
"Food101Class": "mocha",
"imageUrl": "https://images.unsplash.com/photo-1577887233537-a81b387b125e?w=600"
},
{
"Id": "item_demo_tea",
"CategoryId": "cat_demo_drinks",
"Name": "\u0686\u0627\u06CC \u0645\u0627\u0633\u0627\u0644\u0627",
"NameEn": "Masala tea",
"NameAr": "\u0634\u0627\u064A \u0645\u0627\u0633\u0627\u0644\u0627",
"Description": null,
"priceToman": 85000,
"DiscountPercent": 0,
"Food101Class": "miso_soup",
"imageUrl": "https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=600"
},
{
"Id": "item_demo_iced_latte",
"CategoryId": "cat_demo_cold",
"Name": "\u0622\u06CC\u0633 \u0644\u0627\u062A\u0647",
"NameEn": "Iced latte",
"NameAr": "\u0622\u064A\u0633 \u0644\u0627\u062A\u064A\u0647",
"Description": null,
"priceToman": 130000,
"DiscountPercent": 0,
"Food101Class": "iced_coffee",
"imageUrl": "https://images.unsplash.com/photo-1517487881594-2787aeee8f58?w=600\u0026auto=format\u0026fit=crop"
},
{
"Id": "item_demo_cold_brew",
"CategoryId": "cat_demo_cold",
"Name": "\u06A9\u0648\u0644\u062F \u0628\u0631\u0648",
"NameEn": "Cold brew",
"NameAr": "\u0643\u0648\u0644\u062F \u0628\u0631\u0648",
"Description": null,
"priceToman": 140000,
"DiscountPercent": 0,
"Food101Class": "iced_coffee",
"imageUrl": "https://images.unsplash.com/photo-1517487881594-2787aeee8f58?w=600\u0026auto=format\u0026fit=crop"
},
{
"Id": "item_demo_lemonade",
"CategoryId": "cat_demo_cold",
"Name": "\u0644\u06CC\u0645\u0648\u0646\u0627\u062F",
"NameEn": "Lemonade",
"NameAr": "\u0644\u064A\u0645\u0648\u0646\u0627\u062F\u0629",
"Description": "\u062A\u0627\u0632\u0647",
"priceToman": 95000,
"DiscountPercent": 0,
"Food101Class": "lemonade",
"imageUrl": "https://images.unsplash.com/photo-1523672990561-64c16245f769?w=600"
},
{
"Id": "item_demo_smoothie",
"CategoryId": "cat_demo_cold",
"Name": "\u0627\u0633\u0645\u0648\u062A\u06CC \u062A\u0648\u062A",
"NameEn": "Berry smoothie",
"NameAr": "\u0633\u0645\u0648\u0630\u064A",
"Description": null,
"priceToman": 150000,
"DiscountPercent": 15,
"Food101Class": "smoothie",
"imageUrl": "https://images.unsplash.com/photo-1505252585463-0433371f7f6b?w=600"
},
{
"Id": "item_demo_croissant",
"CategoryId": "cat_demo_breakfast",
"Name": "\u06A9\u0631\u0648\u0633\u0627\u0646",
"NameEn": "Croissant",
"NameAr": "\u0643\u0631\u0648\u0627\u0633\u0627\u0646",
"Description": "\u06A9\u0631\u0647\u200C\u0627\u06CC",
"priceToman": 75000,
"DiscountPercent": 0,
"Food101Class": "croque_madame",
"imageUrl": "https://images.unsplash.com/photo-1555507036342-9231d37c10f3?w=600"
},
{
"Id": "item_demo_omelette",
"CategoryId": "cat_demo_breakfast",
"Name": "\u0627\u0645\u0644\u062A",
"NameEn": "Omelette",
"NameAr": "\u0623\u0648\u0645\u0644\u064A\u062A",
"Description": "\u0646\u0627\u0646 \u0633\u0646\u06AF\u06A9",
"priceToman": 145000,
"DiscountPercent": 0,
"Food101Class": "omelette",
"imageUrl": "https://images.unsplash.com/photo-1525351484343-752d43d363f1?w=600"
},
{
"Id": "item_demo_avocado",
"CategoryId": "cat_demo_breakfast",
"Name": "\u062A\u0648\u0633\u062A \u0622\u0648\u0648\u06A9\u0627\u062F\u0648",
"NameEn": "Avocado toast",
"NameAr": "\u062A\u0648\u0633\u062A \u0623\u0641\u0648\u0643\u0627\u062F\u0648",
"Description": null,
"priceToman": 185000,
"DiscountPercent": 0,
"Food101Class": "avocado_toast",
"imageUrl": "https://images.unsplash.com/photo-1541519221064-49632fb3380e?w=600"
},
{
"Id": "item_demo_pancakes",
"CategoryId": "cat_demo_breakfast",
"Name": "\u067E\u0646\u06A9\u06CC\u06A9",
"NameEn": "Pancakes",
"NameAr": "\u0641\u0637\u0627\u0626\u0631",
"Description": "\u0639\u0633\u0644 \u0648 \u06A9\u0631\u0647",
"priceToman": 165000,
"DiscountPercent": 0,
"Food101Class": "pancakes",
"imageUrl": "https://images.unsplash.com/photo-1567620905732-2d1ec7ab7440?w=600"
},
{
"Id": "item_demo_waffles",
"CategoryId": "cat_demo_breakfast",
"Name": "\u0648\u0627\u0641\u0644",
"NameEn": "Waffles",
"NameAr": "\u0648\u0627\u0641\u0644",
"Description": null,
"priceToman": 175000,
"DiscountPercent": 0,
"Food101Class": "waffles",
"imageUrl": "https://images.unsplash.com/photo-1567818735240-7acbb4b7e34c?w=600"
},
{
"Id": "item_demo_french_toast",
"CategoryId": "cat_demo_breakfast",
"Name": "\u0641\u0631\u0646\u0686 \u062A\u0633\u062A",
"NameEn": "French toast",
"NameAr": "\u062A\u0648\u0633\u062A \u0641\u0631\u0646\u0633\u064A",
"Description": null,
"priceToman": 155000,
"DiscountPercent": 0,
"Food101Class": "french_toast",
"imageUrl": "https://images.unsplash.com/photo-1484723091739-30a329e1f0c4?w=600"
},
{
"Id": "item_demo_eggs_benedict",
"CategoryId": "cat_demo_breakfast",
"Name": "\u0627\u06AF \u0628\u0646\u062F\u06CC\u06A9\u062A",
"NameEn": "Eggs Benedict",
"NameAr": "\u0628\u064A\u0636 \u0628\u0646\u062F\u064A\u0643\u062A",
"Description": null,
"priceToman": 195000,
"DiscountPercent": 0,
"Food101Class": "eggs_benedict",
"imageUrl": "https://images.unsplash.com/photo-1608039819502-3d5a2a2e0b0e?w=600"
},
{
"Id": "item_demo_sandwich",
"CategoryId": "cat_demo_food",
"Name": "\u0633\u0627\u0646\u062F\u0648\u06CC\u0686 \u0645\u0631\u063A",
"NameEn": "Chicken sandwich",
"NameAr": "\u0633\u0627\u0646\u062F\u0648\u064A\u062A\u0634 \u062F\u062C\u0627\u062C",
"Description": null,
"priceToman": 195000,
"DiscountPercent": 0,
"Food101Class": "club_sandwich",
"imageUrl": "https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=600"
},
{
"Id": "item_demo_salad",
"CategoryId": "cat_demo_food",
"Name": "\u0633\u0627\u0644\u0627\u062F \u0633\u0632\u0627\u0631",
"NameEn": "Caesar salad",
"NameAr": "\u0633\u0644\u0637\u0629 \u0633\u064A\u0632\u0631",
"Description": null,
"priceToman": 175000,
"DiscountPercent": 0,
"Food101Class": "caesar_salad",
"imageUrl": "https://images.unsplash.com/photo-1546793665-c74683f339c1?w=600"
},
{
"Id": "item_demo_greek_salad",
"CategoryId": "cat_demo_food",
"Name": "\u0633\u0627\u0644\u0627\u062F \u06CC\u0648\u0646\u0627\u0646\u06CC",
"NameEn": "Greek salad",
"NameAr": "\u0633\u0644\u0637\u0629 \u064A\u0648\u0646\u0627\u0646\u064A\u0629",
"Description": null,
"priceToman": 168000,
"DiscountPercent": 0,
"Food101Class": "greek_salad",
"imageUrl": "https://images.unsplash.com/photo-1540189549336-e6e99c3679fe?w=600"
},
{
"Id": "item_demo_burger",
"CategoryId": "cat_demo_food",
"Name": "\u0647\u0645\u0628\u0631\u06AF\u0631",
"NameEn": "Burger",
"NameAr": "\u0628\u0631\u062C\u0631",
"Description": "\u06F1\u06F5\u06F0 \u06AF\u0631\u0645 \u06AF\u0648\u0634\u062A",
"priceToman": 245000,
"DiscountPercent": 0,
"Food101Class": "hamburger",
"imageUrl": "https://images.unsplash.com/photo-1568901346375-23c9450c58cd?w=600"
},
{
"Id": "item_demo_steak",
"CategoryId": "cat_demo_food",
"Name": "\u0627\u0633\u062A\u06CC\u06A9",
"NameEn": "Steak",
"NameAr": "\u0633\u062A\u064A\u0643",
"Description": "medium",
"priceToman": 385000,
"DiscountPercent": 0,
"Food101Class": "steak",
"imageUrl": "https://images.unsplash.com/photo-1600891963295-d66a269b9202?w=600"
},
{
"Id": "item_demo_salmon",
"CategoryId": "cat_demo_food",
"Name": "\u0633\u0627\u0644\u0645\u0648\u0646 \u06AF\u0631\u06CC\u0644",
"NameEn": "Grilled salmon",
"NameAr": "\u0633\u0644\u0645\u0648\u0646 \u0645\u0634\u0648\u064A",
"Description": null,
"priceToman": 320000,
"DiscountPercent": 0,
"Food101Class": "grilled_salmon",
"imageUrl": "https://images.unsplash.com/photo-1467003909585-2f8a72700288?w=600"
},
{
"Id": "item_demo_tacos",
"CategoryId": "cat_demo_food",
"Name": "\u062A\u0627\u06A9\u0648",
"NameEn": "Tacos",
"NameAr": "\u062A\u0627\u0643\u0648",
"Description": "\u0633\u0647 \u0639\u062F\u062F",
"priceToman": 210000,
"DiscountPercent": 0,
"Food101Class": "tacos",
"imageUrl": "https://images.unsplash.com/photo-1565299585323-38174c4aab1e?w=600"
},
{
"Id": "item_demo_shawarma",
"CategoryId": "cat_demo_food",
"Name": "\u0634\u0627\u0648\u0631\u0645\u0627",
"NameEn": "Shawarma",
"NameAr": "\u0634\u0627\u0648\u0631\u0645\u0627",
"Description": null,
"priceToman": 185000,
"DiscountPercent": 0,
"Food101Class": "shawarma",
"imageUrl": "https://images.unsplash.com/photo-1529006557810-274adbcb39d8?w=600"
},
{
"Id": "item_demo_falafel",
"CategoryId": "cat_demo_food",
"Name": "\u0641\u0644\u0627\u0641\u0644",
"NameEn": "Falafel",
"NameAr": "\u0641\u0644\u0627\u0641\u0644",
"Description": "\u06F6 \u0639\u062F\u062F",
"priceToman": 125000,
"DiscountPercent": 0,
"Food101Class": "falafel",
"imageUrl": "https://images.unsplash.com/photo-1601050690597-df5748fb5cee?w=600"
},
{
"Id": "item_demo_hummus",
"CategoryId": "cat_demo_food",
"Name": "\u062D\u0645\u0635",
"NameEn": "Hummus",
"NameAr": "\u062D\u0645\u0635",
"Description": "\u0646\u0627\u0646 \u067E\u06CC\u062A\u0627",
"priceToman": 95000,
"DiscountPercent": 0,
"Food101Class": "hummus",
"imageUrl": "https://images.unsplash.com/photo-1626208082043-e6319abfeec2?w=600"
},
{
"Id": "item_demo_fries",
"CategoryId": "cat_demo_food",
"Name": "\u0633\u06CC\u0628\u200C\u0632\u0645\u06CC\u0646\u06CC \u0633\u0631\u062E\u200C\u06A9\u0631\u062F\u0647",
"NameEn": "French fries",
"NameAr": "\u0628\u0637\u0627\u0637\u0633 \u0645\u0642\u0644\u064A\u0629",
"Description": null,
"priceToman": 85000,
"DiscountPercent": 0,
"Food101Class": "french_fries",
"imageUrl": "https://images.unsplash.com/photo-1573080496219-76b9e6909700?w=600"
},
{
"Id": "item_demo_spring_rolls",
"CategoryId": "cat_demo_food",
"Name": "\u0627\u0633\u067E\u0631\u06CC\u0646\u06AF \u0631\u0648\u0644",
"NameEn": "Spring rolls",
"NameAr": "\u0633\u0628\u0631\u064A\u0646\u063A \u0631\u0648\u0644",
"Description": null,
"priceToman": 115000,
"DiscountPercent": 0,
"Food101Class": "spring_rolls",
"imageUrl": "https://images.unsplash.com/photo-1526318896985-4d29c0903299?w=600"
},
{
"Id": "item_demo_ramen",
"CategoryId": "cat_demo_food",
"Name": "\u0631\u0627\u0645\u0646",
"NameEn": "Ramen",
"NameAr": "\u0631\u0627\u0645\u0646",
"Description": null,
"priceToman": 235000,
"DiscountPercent": 0,
"Food101Class": "ramen",
"imageUrl": "https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=600"
},
{
"Id": "item_demo_pho",
"CategoryId": "cat_demo_food",
"Name": "\u0641\u0648",
"NameEn": "Pho",
"NameAr": "\u0641\u0648",
"Description": null,
"priceToman": 225000,
"DiscountPercent": 0,
"Food101Class": "pho",
"imageUrl": "https://images.unsplash.com/photo-1591814468924-caf36d123dd6?w=600"
},
{
"Id": "item_demo_sushi",
"CategoryId": "cat_demo_food",
"Name": "\u0633\u0648\u0634\u06CC",
"NameEn": "Sushi",
"NameAr": "\u0633\u0648\u0634\u064A",
"Description": "\u06F8 \u062A\u06A9\u0647",
"priceToman": 290000,
"DiscountPercent": 0,
"Food101Class": "sushi",
"imageUrl": "https://images.unsplash.com/photo-1579584425555-c3ce17fd4351?w=600"
},
{
"Id": "item_demo_pasta",
"CategoryId": "cat_demo_pasta",
"Name": "\u067E\u0627\u0633\u062A\u0627 \u0622\u0644\u0641\u0631\u062F\u0648",
"NameEn": "Alfredo pasta",
"NameAr": "\u0628\u0627\u0633\u062A\u0627",
"Description": null,
"priceToman": 220000,
"DiscountPercent": 0,
"Food101Class": "pasta_carbonara",
"imageUrl": "https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?w=600"
},
{
"Id": "item_demo_carbonara",
"CategoryId": "cat_demo_pasta",
"Name": "\u06A9\u0627\u0631\u0628\u0648\u0646\u0627\u0631\u0627",
"NameEn": "Carbonara",
"NameAr": "\u0643\u0627\u0631\u0628\u0648\u0646\u0627\u0631\u0627",
"Description": null,
"priceToman": 228000,
"DiscountPercent": 0,
"Food101Class": "spaghetti_carbonara",
"imageUrl": "https://images.unsplash.com/photo-1612874741227-866d1aeeecd1?w=600"
},
{
"Id": "item_demo_bolognese",
"CategoryId": "cat_demo_pasta",
"Name": "\u0628\u0648\u0644\u0648\u0646\u0632",
"NameEn": "Bolognese",
"NameAr": "\u0628\u0648\u0644\u0648\u0646\u064A\u0632",
"Description": null,
"priceToman": 215000,
"DiscountPercent": 0,
"Food101Class": "spaghetti_bolognese",
"imageUrl": "https://images.unsplash.com/photo-1622973536968-77544a8a4e0e?w=600"
},
{
"Id": "item_demo_lasagna",
"CategoryId": "cat_demo_pasta",
"Name": "\u0644\u0627\u0632\u0627\u0646\u06CC\u0627",
"NameEn": "Lasagna",
"NameAr": "\u0644\u0627\u0632\u0627\u0646\u064A\u0627",
"Description": null,
"priceToman": 240000,
"DiscountPercent": 0,
"Food101Class": "lasagna",
"imageUrl": "https://images.unsplash.com/photo-1574894709920-11b28e7367e3?w=600"
},
{
"Id": "item_demo_gnocchi",
"CategoryId": "cat_demo_pasta",
"Name": "\u0646\u06CC\u0648\u06A9\u06CC",
"NameEn": "Gnocchi",
"NameAr": "\u062C\u0646\u0648\u0643\u064A",
"Description": null,
"priceToman": 232000,
"DiscountPercent": 0,
"Food101Class": "gnocchi",
"imageUrl": "https://images.unsplash.com/photo-1551183053-bf33a48c970a?w=600"
},
{
"Id": "item_demo_pizza",
"CategoryId": "cat_demo_pasta",
"Name": "\u067E\u06CC\u062A\u0632\u0627 \u0645\u0627\u0631\u06AF\u0627\u0631\u06CC\u062A\u0627",
"NameEn": "Margherita pizza",
"NameAr": "\u0628\u064A\u062A\u0632\u0627",
"Description": null,
"priceToman": 265000,
"DiscountPercent": 10,
"Food101Class": "pizza",
"imageUrl": "https://images.unsplash.com/photo-1513104890138-7c749659a591?w=600"
},
{
"Id": "item_demo_risotto",
"CategoryId": "cat_demo_pasta",
"Name": "\u0631\u06CC\u0632\u0648\u062A\u0648 \u0642\u0627\u0631\u0686",
"NameEn": "Mushroom risotto",
"NameAr": "\u0631\u064A\u0632\u0648\u062A\u0648",
"Description": null,
"priceToman": 238000,
"DiscountPercent": 0,
"Food101Class": "mushroom_risotto",
"imageUrl": "https://images.unsplash.com/photo-1476124362071-b9f2c96ef2db?w=600"
},
{
"Id": "item_demo_cake",
"CategoryId": "cat_demo_dessert",
"Name": "\u06A9\u06CC\u06A9 \u0634\u06A9\u0644\u0627\u062A\u06CC",
"NameEn": "Chocolate cake",
"NameAr": "\u0643\u064A\u0643 \u0634\u0648\u0643\u0648\u0644\u0627\u062A\u0629",
"Description": "\u0628\u0631\u0634\u06CC",
"priceToman": 95000,
"DiscountPercent": 15,
"Food101Class": "chocolate_cake",
"imageUrl": "https://images.unsplash.com/photo-1578985545062-69928b1d9587?w=600"
},
{
"Id": "item_demo_cheesecake",
"CategoryId": "cat_demo_dessert",
"Name": "\u0686\u06CC\u0632\u06A9\u06CC\u06A9",
"NameEn": "Cheesecake",
"NameAr": "\u062A\u0634\u064A\u0632 \u0643\u064A\u0643",
"Description": null,
"priceToman": 115000,
"DiscountPercent": 0,
"Food101Class": "cheesecake",
"imageUrl": "https://images.unsplash.com/photo-1524351199678-941a58cfcc36?w=600"
},
{
"Id": "item_demo_brownie",
"CategoryId": "cat_demo_dessert",
"Name": "\u0628\u0631\u0627\u0648\u0646\u06CC",
"NameEn": "Brownie",
"NameAr": "\u0628\u0631\u0627\u0648\u0646\u064A",
"Description": "\u0628\u0633\u062A\u0646\u06CC \u0648\u0627\u0646\u06CC\u0644\u06CC",
"priceToman": 105000,
"DiscountPercent": 0,
"Food101Class": "brownie",
"imageUrl": "https://images.unsplash.com/photo-1606313564200-e75d5e30476e?w=600"
},
{
"Id": "item_demo_icecream",
"CategoryId": "cat_demo_dessert",
"Name": "\u0628\u0633\u062A\u0646\u06CC",
"NameEn": "Ice cream",
"NameAr": "\u0622\u064A\u0633 \u0643\u0631\u064A\u0645",
"Description": "\u062F\u0648 \u0627\u0633\u06A9\u0648\u067E",
"priceToman": 88000,
"DiscountPercent": 0,
"Food101Class": "ice_cream",
"imageUrl": "https://images.unsplash.com/photo-1563805042-7684c019e1cb?w=600"
},
{
"Id": "item_demo_tiramisu",
"CategoryId": "cat_demo_dessert",
"Name": "\u062A\u06CC\u0631\u0627\u0645\u06CC\u0633\u0648",
"NameEn": "Tiramisu",
"NameAr": "\u062A\u064A\u0631\u0627\u0645\u064A\u0633\u0648",
"Description": null,
"priceToman": 125000,
"DiscountPercent": 0,
"Food101Class": "tiramisu",
"imageUrl": "https://images.unsplash.com/photo-1571877227200-a0d98ea607e9?w=600"
},
{
"Id": "item_demo_donuts",
"CategoryId": "cat_demo_dessert",
"Name": "\u062F\u0648\u0646\u0627\u062A",
"NameEn": "Donuts",
"NameAr": "\u062F\u0648\u0646\u0627\u062A",
"Description": null,
"priceToman": 78000,
"DiscountPercent": 0,
"Food101Class": "donuts",
"imageUrl": "https://images.unsplash.com/photo-1551024506-0bccd28d3071?w=600"
},
{
"Id": "item_demo_churros",
"CategoryId": "cat_demo_dessert",
"Name": "\u0686\u0648\u0631\u0648\u0633",
"NameEn": "Churros",
"NameAr": "\u062A\u0634\u0648\u0631\u0648",
"Description": "\u0634\u06A9\u0644\u0627\u062A",
"priceToman": 92000,
"DiscountPercent": 0,
"Food101Class": "churros",
"imageUrl": "https://images.unsplash.com/photo-1627482299165-0883a056a48f?w=600"
},
{
"Id": "item_demo_baklava",
"CategoryId": "cat_demo_dessert",
"Name": "\u0628\u0627\u0642\u0644\u0648\u0627",
"NameEn": "Baklava",
"NameAr": "\u0628\u0642\u0644\u0627\u0648\u0629",
"Description": null,
"priceToman": 98000,
"DiscountPercent": 0,
"Food101Class": "baklava",
"imageUrl": "https://images.unsplash.com/photo-1598110756419-84b30681f41f?w=600"
},
{
"Id": "item_demo_creme_brulee",
"CategoryId": "cat_demo_dessert",
"Name": "\u06A9\u0631\u0645 \u0628\u0631\u0648\u0644\u0647",
"NameEn": "Cr\u00E8me br\u00FBl\u00E9e",
"NameAr": "\u0643\u0631\u064A\u0645 \u0628\u0631\u0648\u0644\u064A\u0647",
"Description": null,
"priceToman": 118000,
"DiscountPercent": 0,
"Food101Class": "creme_brulee",
"imageUrl": "https://images.unsplash.com/photo-1470309864661-683be0ef7eaf?w=600"
}
]
}
+298
View File
@@ -0,0 +1,298 @@
{
"version": 2,
"source": "Food-101 class mapping (Unsplash fallbacks until Kaggle JPEG import)",
"defaults": {
"drink": "https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=600\u0026auto=format\u0026fit=crop",
"food": "https://images.unsplash.com/photo-1546793665-c74683f339c1?w=600"
},
"items": {
"item_demo_espresso": {
"food101Class": "espresso",
"imageUrl": "https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=600\u0026auto=format\u0026fit=crop",
"nameEn": "Espresso",
"categoryId": "cat_demo_drinks"
},
"item_demo_americano": {
"food101Class": "cappuccino",
"imageUrl": "https://images.unsplash.com/photo-1572442388796-11668a67e3d0?w=600",
"nameEn": "Americano",
"categoryId": "cat_demo_drinks"
},
"item_demo_latte": {
"food101Class": "latte",
"imageUrl": "https://images.unsplash.com/photo-1461023058943-07fcbe16d735?w=600",
"nameEn": "Latte",
"categoryId": "cat_demo_drinks"
},
"item_demo_cappuccino": {
"food101Class": "cappuccino",
"imageUrl": "https://images.unsplash.com/photo-1572442388796-11668a67e3d0?w=600",
"nameEn": "Cappuccino",
"categoryId": "cat_demo_drinks"
},
"item_demo_mocha": {
"food101Class": "mocha",
"imageUrl": "https://images.unsplash.com/photo-1577887233537-a81b387b125e?w=600",
"nameEn": "Mocha",
"categoryId": "cat_demo_drinks"
},
"item_demo_tea": {
"food101Class": "miso_soup",
"imageUrl": "https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=600",
"nameEn": "Masala tea",
"categoryId": "cat_demo_drinks"
},
"item_demo_iced_latte": {
"food101Class": "iced_coffee",
"imageUrl": "https://images.unsplash.com/photo-1517487881594-2787aeee8f58?w=600\u0026auto=format\u0026fit=crop",
"nameEn": "Iced latte",
"categoryId": "cat_demo_cold"
},
"item_demo_cold_brew": {
"food101Class": "iced_coffee",
"imageUrl": "https://images.unsplash.com/photo-1517487881594-2787aeee8f58?w=600\u0026auto=format\u0026fit=crop",
"nameEn": "Cold brew",
"categoryId": "cat_demo_cold"
},
"item_demo_lemonade": {
"food101Class": "lemonade",
"imageUrl": "https://images.unsplash.com/photo-1523672990561-64c16245f769?w=600",
"nameEn": "Lemonade",
"categoryId": "cat_demo_cold"
},
"item_demo_smoothie": {
"food101Class": "smoothie",
"imageUrl": "https://images.unsplash.com/photo-1505252585463-0433371f7f6b?w=600",
"nameEn": "Berry smoothie",
"categoryId": "cat_demo_cold"
},
"item_demo_croissant": {
"food101Class": "croque_madame",
"imageUrl": "https://images.unsplash.com/photo-1555507036342-9231d37c10f3?w=600",
"nameEn": "Croissant",
"categoryId": "cat_demo_breakfast"
},
"item_demo_omelette": {
"food101Class": "omelette",
"imageUrl": "https://images.unsplash.com/photo-1525351484343-752d43d363f1?w=600",
"nameEn": "Omelette",
"categoryId": "cat_demo_breakfast"
},
"item_demo_avocado": {
"food101Class": "avocado_toast",
"imageUrl": "https://images.unsplash.com/photo-1541519221064-49632fb3380e?w=600",
"nameEn": "Avocado toast",
"categoryId": "cat_demo_breakfast"
},
"item_demo_pancakes": {
"food101Class": "pancakes",
"imageUrl": "https://images.unsplash.com/photo-1567620905732-2d1ec7ab7440?w=600",
"nameEn": "Pancakes",
"categoryId": "cat_demo_breakfast"
},
"item_demo_waffles": {
"food101Class": "waffles",
"imageUrl": "https://images.unsplash.com/photo-1567818735240-7acbb4b7e34c?w=600",
"nameEn": "Waffles",
"categoryId": "cat_demo_breakfast"
},
"item_demo_french_toast": {
"food101Class": "french_toast",
"imageUrl": "https://images.unsplash.com/photo-1484723091739-30a329e1f0c4?w=600",
"nameEn": "French toast",
"categoryId": "cat_demo_breakfast"
},
"item_demo_eggs_benedict": {
"food101Class": "eggs_benedict",
"imageUrl": "https://images.unsplash.com/photo-1608039819502-3d5a2a2e0b0e?w=600",
"nameEn": "Eggs Benedict",
"categoryId": "cat_demo_breakfast"
},
"item_demo_sandwich": {
"food101Class": "club_sandwich",
"imageUrl": "https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=600",
"nameEn": "Chicken sandwich",
"categoryId": "cat_demo_food"
},
"item_demo_salad": {
"food101Class": "caesar_salad",
"imageUrl": "https://images.unsplash.com/photo-1546793665-c74683f339c1?w=600",
"nameEn": "Caesar salad",
"categoryId": "cat_demo_food"
},
"item_demo_greek_salad": {
"food101Class": "greek_salad",
"imageUrl": "https://images.unsplash.com/photo-1540189549336-e6e99c3679fe?w=600",
"nameEn": "Greek salad",
"categoryId": "cat_demo_food"
},
"item_demo_burger": {
"food101Class": "hamburger",
"imageUrl": "https://images.unsplash.com/photo-1568901346375-23c9450c58cd?w=600",
"nameEn": "Burger",
"categoryId": "cat_demo_food"
},
"item_demo_steak": {
"food101Class": "steak",
"imageUrl": "https://images.unsplash.com/photo-1600891963295-d66a269b9202?w=600",
"nameEn": "Steak",
"categoryId": "cat_demo_food"
},
"item_demo_salmon": {
"food101Class": "grilled_salmon",
"imageUrl": "https://images.unsplash.com/photo-1467003909585-2f8a72700288?w=600",
"nameEn": "Grilled salmon",
"categoryId": "cat_demo_food"
},
"item_demo_tacos": {
"food101Class": "tacos",
"imageUrl": "https://images.unsplash.com/photo-1565299585323-38174c4aab1e?w=600",
"nameEn": "Tacos",
"categoryId": "cat_demo_food"
},
"item_demo_shawarma": {
"food101Class": "shawarma",
"imageUrl": "https://images.unsplash.com/photo-1529006557810-274adbcb39d8?w=600",
"nameEn": "Shawarma",
"categoryId": "cat_demo_food"
},
"item_demo_falafel": {
"food101Class": "falafel",
"imageUrl": "https://images.unsplash.com/photo-1601050690597-df5748fb5cee?w=600",
"nameEn": "Falafel",
"categoryId": "cat_demo_food"
},
"item_demo_hummus": {
"food101Class": "hummus",
"imageUrl": "https://images.unsplash.com/photo-1626208082043-e6319abfeec2?w=600",
"nameEn": "Hummus",
"categoryId": "cat_demo_food"
},
"item_demo_fries": {
"food101Class": "french_fries",
"imageUrl": "https://images.unsplash.com/photo-1573080496219-76b9e6909700?w=600",
"nameEn": "French fries",
"categoryId": "cat_demo_food"
},
"item_demo_spring_rolls": {
"food101Class": "spring_rolls",
"imageUrl": "https://images.unsplash.com/photo-1526318896985-4d29c0903299?w=600",
"nameEn": "Spring rolls",
"categoryId": "cat_demo_food"
},
"item_demo_ramen": {
"food101Class": "ramen",
"imageUrl": "https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=600",
"nameEn": "Ramen",
"categoryId": "cat_demo_food"
},
"item_demo_pho": {
"food101Class": "pho",
"imageUrl": "https://images.unsplash.com/photo-1591814468924-caf36d123dd6?w=600",
"nameEn": "Pho",
"categoryId": "cat_demo_food"
},
"item_demo_sushi": {
"food101Class": "sushi",
"imageUrl": "https://images.unsplash.com/photo-1579584425555-c3ce17fd4351?w=600",
"nameEn": "Sushi",
"categoryId": "cat_demo_food"
},
"item_demo_pasta": {
"food101Class": "pasta_carbonara",
"imageUrl": "https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?w=600",
"nameEn": "Alfredo pasta",
"categoryId": "cat_demo_pasta"
},
"item_demo_carbonara": {
"food101Class": "spaghetti_carbonara",
"imageUrl": "https://images.unsplash.com/photo-1612874741227-866d1aeeecd1?w=600",
"nameEn": "Carbonara",
"categoryId": "cat_demo_pasta"
},
"item_demo_bolognese": {
"food101Class": "spaghetti_bolognese",
"imageUrl": "https://images.unsplash.com/photo-1622973536968-77544a8a4e0e?w=600",
"nameEn": "Bolognese",
"categoryId": "cat_demo_pasta"
},
"item_demo_lasagna": {
"food101Class": "lasagna",
"imageUrl": "https://images.unsplash.com/photo-1574894709920-11b28e7367e3?w=600",
"nameEn": "Lasagna",
"categoryId": "cat_demo_pasta"
},
"item_demo_gnocchi": {
"food101Class": "gnocchi",
"imageUrl": "https://images.unsplash.com/photo-1551183053-bf33a48c970a?w=600",
"nameEn": "Gnocchi",
"categoryId": "cat_demo_pasta"
},
"item_demo_pizza": {
"food101Class": "pizza",
"imageUrl": "https://images.unsplash.com/photo-1513104890138-7c749659a591?w=600",
"nameEn": "Margherita pizza",
"categoryId": "cat_demo_pasta"
},
"item_demo_risotto": {
"food101Class": "mushroom_risotto",
"imageUrl": "https://images.unsplash.com/photo-1476124362071-b9f2c96ef2db?w=600",
"nameEn": "Mushroom risotto",
"categoryId": "cat_demo_pasta"
},
"item_demo_cake": {
"food101Class": "chocolate_cake",
"imageUrl": "https://images.unsplash.com/photo-1578985545062-69928b1d9587?w=600",
"nameEn": "Chocolate cake",
"categoryId": "cat_demo_dessert"
},
"item_demo_cheesecake": {
"food101Class": "cheesecake",
"imageUrl": "https://images.unsplash.com/photo-1524351199678-941a58cfcc36?w=600",
"nameEn": "Cheesecake",
"categoryId": "cat_demo_dessert"
},
"item_demo_brownie": {
"food101Class": "brownie",
"imageUrl": "https://images.unsplash.com/photo-1606313564200-e75d5e30476e?w=600",
"nameEn": "Brownie",
"categoryId": "cat_demo_dessert"
},
"item_demo_icecream": {
"food101Class": "ice_cream",
"imageUrl": "https://images.unsplash.com/photo-1563805042-7684c019e1cb?w=600",
"nameEn": "Ice cream",
"categoryId": "cat_demo_dessert"
},
"item_demo_tiramisu": {
"food101Class": "tiramisu",
"imageUrl": "https://images.unsplash.com/photo-1571877227200-a0d98ea607e9?w=600",
"nameEn": "Tiramisu",
"categoryId": "cat_demo_dessert"
},
"item_demo_donuts": {
"food101Class": "donuts",
"imageUrl": "https://images.unsplash.com/photo-1551024506-0bccd28d3071?w=600",
"nameEn": "Donuts",
"categoryId": "cat_demo_dessert"
},
"item_demo_churros": {
"food101Class": "churros",
"imageUrl": "https://images.unsplash.com/photo-1627482299165-0883a056a48f?w=600",
"nameEn": "Churros",
"categoryId": "cat_demo_dessert"
},
"item_demo_baklava": {
"food101Class": "baklava",
"imageUrl": "https://images.unsplash.com/photo-1598110756419-84b30681f41f?w=600",
"nameEn": "Baklava",
"categoryId": "cat_demo_dessert"
},
"item_demo_creme_brulee": {
"food101Class": "creme_brulee",
"imageUrl": "https://images.unsplash.com/photo-1470309864661-683be0ef7eaf?w=600",
"nameEn": "Cr\u00E8me br\u00FBl\u00E9e",
"categoryId": "cat_demo_dessert"
}
}
}
BIN
View File
Binary file not shown.
@@ -0,0 +1,34 @@
namespace Meezi.API.Configuration;
public class DeliveryPlatformsOptions
{
public const string SectionName = "DeliveryPlatforms";
public SnappfoodPlatformOptions Snappfood { get; set; } = new();
public Tap30PlatformOptions Tap30 { get; set; } = new();
public DigikalaPlatformOptions Digikala { get; set; } = new();
public decimal DefaultSnappfoodCommissionPercent { get; set; } = 18m;
public decimal DefaultTap30CommissionPercent { get; set; } = 15m;
public decimal DefaultDigikalaCommissionPercent { get; set; } = 12m;
}
public class SnappfoodPlatformOptions
{
public string WebhookSecret { get; set; } = "";
public string ApiKey { get; set; } = "";
public string ApiBaseUrl { get; set; } = "";
}
public class Tap30PlatformOptions
{
public string WebhookSecret { get; set; } = "";
public string ApiKey { get; set; } = "";
public string ApiBaseUrl { get; set; } = "";
}
public class DigikalaPlatformOptions
{
public string WebhookSecret { get; set; } = "";
public bool Enabled { get; set; }
}
@@ -0,0 +1,14 @@
namespace Meezi.API.Configuration;
public class MenuAi3dOptions
{
public const string SectionName = "Ai3d";
public string ApiKey { get; set; } = "";
public string BaseUrl { get; set; } = "https://api.meshy.ai";
public string ImageTo3dPath { get; set; } = "/openapi/v1/image-to-3d";
public int PollIntervalSeconds { get; set; } = 4;
public int PollTimeoutSeconds { get; set; } = 300;
/// <summary>When true and ApiKey is empty, returns a minimal GLB (development only).</summary>
public bool AllowDevStub { get; set; }
}
+1
View File
@@ -0,0 +1 @@
+125
View File
@@ -0,0 +1,125 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Meezi.API.Models.Auth;
using Meezi.API.Services;
using Meezi.Core.Constants;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
private readonly IValidator<SendOtpRequest> _sendOtpValidator;
private readonly IValidator<VerifyOtpRequest> _verifyOtpValidator;
private readonly IValidator<RefreshTokenRequest> _refreshValidator;
public AuthController(
IAuthService authService,
IValidator<SendOtpRequest> sendOtpValidator,
IValidator<VerifyOtpRequest> verifyOtpValidator,
IValidator<RefreshTokenRequest> refreshValidator)
{
_authService = authService;
_sendOtpValidator = sendOtpValidator;
_verifyOtpValidator = verifyOtpValidator;
_refreshValidator = refreshValidator;
}
[HttpPost("send-otp")]
[EnableRateLimiting("auth-otp")]
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> SendOtp([FromBody] SendOtpRequest request, CancellationToken cancellationToken)
{
var validation = await _sendOtpValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid)
return BadRequest(ValidationError(validation));
var (success, data, code, message) = await _authService.SendOtpAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<SendOtpResponse>(true, data));
}
[HttpPost("verify-otp")]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> VerifyOtp([FromBody] VerifyOtpRequest request, CancellationToken cancellationToken)
{
var validation = await _verifyOtpValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid)
return BadRequest(ValidationError(validation));
var (success, data, code, message) = await _authService.VerifyOtpAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("refresh")]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
{
var validation = await _refreshValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid)
return BadRequest(ValidationError(validation));
var (success, data, code, message) = await _authService.RefreshAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public IActionResult GetMe()
{
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? string.Empty;
var expClaim = User.FindFirstValue(JwtRegisteredClaimNames.Exp);
var expiresAt = expClaim != null && long.TryParse(expClaim, out var exp)
? DateTimeOffset.FromUnixTimeSeconds(exp).UtcDateTime
: DateTime.UtcNow;
var data = new AuthTokenResponse(
AccessToken: string.Empty,
RefreshToken: string.Empty,
ExpiresAt: expiresAt,
UserId: userId,
CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty,
Role: User.FindFirstValue(MeeziClaimTypes.Role) ?? string.Empty,
PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty,
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
BranchId: User.FindFirstValue(MeeziClaimTypes.BranchId));
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
private static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation)
{
var first = validation.Errors.First();
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
}
private IActionResult ErrorResult(string code, string message) => code switch
{
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
new ApiResponse<object>(false, null, new ApiError(code, message))),
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
};
}
@@ -0,0 +1,106 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Billing;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[ApiController]
public class BillingController : ControllerBase
{
private readonly IBillingService _billing;
private readonly IValidator<SubscribeRequest> _subscribeValidator;
public BillingController(IBillingService billing, IValidator<SubscribeRequest> subscribeValidator)
{
_billing = billing;
_subscribeValidator = subscribeValidator;
}
[Authorize]
[HttpPost("api/billing/subscribe")]
public async Task<IActionResult> Subscribe(
[FromBody] SubscribeRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (string.IsNullOrEmpty(tenant.CafeId))
return Unauthorized();
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
{
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
}
var validation = await _subscribeValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var (data, code, message) = await _billing.InitiateSubscriptionAsync(tenant.CafeId, request, ct);
if (data is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
return Ok(new ApiResponse<SubscribeResponse>(true, data));
}
[AllowAnonymous]
[HttpGet("api/billing/verify")]
public async Task<IActionResult> Verify(
[FromQuery] string Authority,
[FromQuery] string? Status,
CancellationToken ct)
{
var result = await _billing.VerifyZarinPalAsync(Authority, Status, ct);
return Redirect(result.RedirectUrl);
}
[AllowAnonymous]
[HttpGet("api/billing/verify/snapppay")]
public async Task<IActionResult> VerifySnappPay(
[FromQuery] string? paymentToken,
[FromQuery] string? state,
CancellationToken ct)
{
var result = await _billing.VerifySnappPayAsync(paymentToken, state, ct);
return Redirect(result.RedirectUrl);
}
[AllowAnonymous]
[HttpGet("api/billing/verify/tara")]
public async Task<IActionResult> VerifyTara(
[FromQuery] string? traceNumber,
[FromQuery] string? status,
CancellationToken ct)
{
var result = await _billing.VerifyTaraAsync(traceNumber, status, ct);
return Redirect(result.RedirectUrl);
}
[Authorize]
[HttpGet("api/billing/payment-methods")]
public async Task<IActionResult> PaymentMethods(CancellationToken ct)
{
var methods = await _billing.GetPaymentMethodsAsync(ct);
return Ok(new ApiResponse<IReadOnlyList<PaymentMethodDto>>(true, methods));
}
[Authorize]
[HttpGet("api/billing/status")]
public async Task<IActionResult> Status(ITenantContext tenant, CancellationToken ct)
{
if (string.IsNullOrEmpty(tenant.CafeId) || tenant.PlanTier is null)
return Unauthorized();
var data = await _billing.GetStatusAsync(tenant.CafeId, tenant.PlanTier.Value, ct);
if (data is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
return Ok(new ApiResponse<BillingStatusDto>(true, data));
}
}
@@ -0,0 +1,104 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Menu;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/branches/{branchId}/menu")]
public class BranchMenuController : CafeApiControllerBase
{
private readonly IBranchMenuService _branchMenu;
private readonly IValidator<UpsertBranchMenuOverrideRequest> _upsertValidator;
public BranchMenuController(
IBranchMenuService branchMenu,
IValidator<UpsertBranchMenuOverrideRequest> upsertValidator)
{
_branchMenu = branchMenu;
_upsertValidator = upsertValidator;
}
[HttpGet]
public async Task<IActionResult> GetBranchMenu(
string cafeId,
string branchId,
ITenantContext tenant,
CancellationToken cancellationToken,
[FromQuery] bool includeUnavailable = false)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _branchMenu.GetBranchMenuAsync(
cafeId, branchId, includeUnavailable, cancellationToken);
if (data is null)
return NotFound(new ApiResponse<object>(
false, null, new ApiError("BRANCH_NOT_FOUND", "Branch not found.")));
return Ok(new ApiResponse<IReadOnlyList<BranchMenuItemDto>>(true, data));
}
[HttpPut("{menuItemId}/override")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> UpsertOverride(
string cafeId,
string branchId,
string menuItemId,
[FromBody] UpsertBranchMenuOverrideRequest request,
ITenantContext tenant,
CancellationToken cancellationToken = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!BranchMenuService.CanManageOverrides(tenant.Role))
return Forbid();
var validation = await _upsertValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var plan = tenant.PlanTier ?? PlanTier.Free;
var result = await _branchMenu.UpsertOverrideAsync(
cafeId,
branchId,
menuItemId,
request,
plan,
tenant.Role,
tenant.UserId,
cancellationToken);
if (!result.Success)
{
var status = result.ErrorCode == "PLAN_LIMIT_REACHED"
? StatusCodes.Status403Forbidden
: StatusCodes.Status400BadRequest;
return StatusCode(status,
new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode!, result.Message!)));
}
return Ok(new ApiResponse<BranchMenuOverrideDto>(true, result.Data));
}
[HttpDelete("{menuItemId}/override")]
[Authorize(Roles = "Owner")]
public async Task<IActionResult> DeleteOverride(
string cafeId,
string branchId,
string menuItemId,
ITenantContext tenant,
CancellationToken cancellationToken = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _branchMenu.DeleteOverrideAsync(
cafeId, branchId, menuItemId, cancellationToken);
if (!deleted)
return NotFoundError("Override not found.");
return Ok(new ApiResponse<object>(true, new { menuItemId }));
}
}
@@ -0,0 +1,114 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Printing;
using Meezi.Core.Entities;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/branches/{branchId}/print-settings")]
[Authorize(Roles = "Manager,Owner")]
public class BranchPrintSettingsController : CafeApiControllerBase
{
private readonly AppDbContext _db;
private readonly IValidator<PatchBranchPrintSettingsRequest> _validator;
public BranchPrintSettingsController(
AppDbContext db,
IValidator<PatchBranchPrintSettingsRequest> validator)
{
_db = db;
_validator = validator;
}
[HttpGet]
public async Task<IActionResult> Get(
string cafeId,
string branchId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var branch = await _db.Branches
.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
if (branch is null)
return NotFound(new ApiResponse<object>(false, null,
new ApiError("BRANCH_NOT_FOUND", "Branch not found.")));
return Ok(new ApiResponse<BranchPrintSettingsDto>(true, ToDto(branch)));
}
[HttpPatch]
public async Task<IActionResult> Patch(
string cafeId,
string branchId,
[FromBody] PatchBranchPrintSettingsRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var branch = await _db.Branches.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
if (branch is null)
return NotFound(new ApiResponse<object>(false, null,
new ApiError("BRANCH_NOT_FOUND", "Branch not found.")));
if (request.ReceiptPrinterIp is not null)
branch.ReceiptPrinterIp = string.IsNullOrWhiteSpace(request.ReceiptPrinterIp)
? null
: request.ReceiptPrinterIp.Trim();
if (request.ReceiptPrinterPort.HasValue)
branch.ReceiptPrinterPort = request.ReceiptPrinterPort.Value;
if (request.KitchenPrinterIp is not null)
branch.KitchenPrinterIp = string.IsNullOrWhiteSpace(request.KitchenPrinterIp)
? null
: request.KitchenPrinterIp.Trim();
if (request.KitchenPrinterPort.HasValue)
branch.KitchenPrinterPort = request.KitchenPrinterPort.Value;
if (request.PaperWidthMm.HasValue)
branch.PaperWidthMm = request.PaperWidthMm.Value is 58 or 80 ? request.PaperWidthMm.Value : 80;
if (request.AutoCutEnabled.HasValue)
branch.AutoCutEnabled = request.AutoCutEnabled.Value;
if (request.ReceiptHeader is not null)
branch.ReceiptHeader = string.IsNullOrWhiteSpace(request.ReceiptHeader) ? null : request.ReceiptHeader.Trim();
if (request.ReceiptFooter is not null)
branch.ReceiptFooter = string.IsNullOrWhiteSpace(request.ReceiptFooter) ? null : request.ReceiptFooter.Trim();
if (request.WifiPassword is not null)
branch.WifiPassword = string.IsNullOrWhiteSpace(request.WifiPassword) ? null : request.WifiPassword.Trim();
if (request.PosDeviceIp is not null)
branch.PosDeviceIp = string.IsNullOrWhiteSpace(request.PosDeviceIp)
? null
: request.PosDeviceIp.Trim();
if (request.PosDevicePort.HasValue)
branch.PosDevicePort = request.PosDevicePort.Value;
branch.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<BranchPrintSettingsDto>(true, ToDto(branch)));
}
private static BranchPrintSettingsDto ToDto(Branch b) => new(
b.Id,
b.ReceiptPrinterIp,
b.ReceiptPrinterPort,
b.KitchenPrinterIp,
b.KitchenPrinterPort,
b.PaperWidthMm is 58 or 80 ? b.PaperWidthMm : 80,
b.AutoCutEnabled,
b.ReceiptHeader,
b.ReceiptFooter,
b.WifiPassword,
b.PosDeviceIp,
b.PosDevicePort);
}
@@ -0,0 +1,253 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Tables;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/branches/{branchId}/tables")]
public class BranchTablesController : CafeApiControllerBase
{
private readonly ITableService _tables;
private readonly IValidator<CreateBranchTableRequest> _createTableValidator;
private readonly IValidator<PatchBranchTableRequest> _patchTableValidator;
private readonly IValidator<CreateTableSectionRequest> _createSectionValidator;
private readonly IValidator<PatchTableSectionRequest> _patchSectionValidator;
private readonly IValidator<SetTableCleaningRequest> _cleaningValidator;
public BranchTablesController(
ITableService tables,
IValidator<CreateBranchTableRequest> createTableValidator,
IValidator<PatchBranchTableRequest> patchTableValidator,
IValidator<CreateTableSectionRequest> createSectionValidator,
IValidator<PatchTableSectionRequest> patchSectionValidator,
IValidator<SetTableCleaningRequest> cleaningValidator)
{
_tables = tables;
_createTableValidator = createTableValidator;
_patchTableValidator = patchTableValidator;
_createSectionValidator = createSectionValidator;
_patchSectionValidator = patchSectionValidator;
_cleaningValidator = cleaningValidator;
}
[HttpGet("board")]
public async Task<IActionResult> GetBoard(
string cafeId,
string branchId,
ITenantContext tenant,
[FromQuery] bool activeOnly = true,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
var data = await _tables.GetBranchTableBoardAsync(cafeId, branchId, activeOnly, ct);
return Ok(new ApiResponse<IReadOnlyList<TableBoardDto>>(true, data));
}
[HttpGet]
public async Task<IActionResult> GetTables(
string cafeId,
string branchId,
ITenantContext tenant,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
var data = await _tables.GetBranchTablesAsync(cafeId, branchId, ct);
if (data is null) return NotFoundError("Branch not found.");
return Ok(new ApiResponse<IReadOnlyList<TableDto>>(true, data));
}
[HttpPost]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> CreateTable(
string cafeId,
string branchId,
[FromBody] CreateBranchTableRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
var validation = await _createTableValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _tables.CreateBranchTableAsync(cafeId, branchId, request, ct);
return BranchOpResult(result);
}
[HttpPatch("{id}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> PatchTable(
string cafeId,
string branchId,
string id,
[FromBody] PatchBranchTableRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
var validation = await _patchTableValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _tables.PatchBranchTableAsync(cafeId, branchId, id, request, ct);
return BranchOpResult(result);
}
[HttpDelete("{id}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> DeleteTable(
string cafeId,
string branchId,
string id,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
var result = await _tables.DeleteBranchTableAsync(cafeId, branchId, id, ct);
return BranchOpResult(result);
}
[HttpPatch("{id}/cleaning")]
public async Task<IActionResult> SetCleaning(
string cafeId,
string branchId,
string id,
[FromBody] SetTableCleaningRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
var validation = await _cleaningValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _tables.SetTableCleaningAsync(cafeId, id, request.IsCleaning, ct);
if (data is null || data.BranchId != branchId) return NotFoundError();
return Ok(new ApiResponse<TableBoardDto>(true, data));
}
[HttpGet("{id}/qr")]
public async Task<IActionResult> GetQrPng(
string cafeId,
string branchId,
string id,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
var png = await _tables.GetQrPngAsync(cafeId, id, ct);
if (png is null) return NotFoundError();
return File(png, "image/png", $"table-{id}-qr.png");
}
[HttpGet("sections")]
public async Task<IActionResult> GetSections(
string cafeId,
string branchId,
ITenantContext tenant,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
var data = await _tables.GetBranchSectionsAsync(cafeId, branchId, ct);
if (data is null) return NotFoundError("Branch not found.");
return Ok(new ApiResponse<IReadOnlyList<TableSectionDto>>(true, data));
}
[HttpPost("sections")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> CreateSection(
string cafeId,
string branchId,
[FromBody] CreateTableSectionRequest request,
ITenantContext tenant,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
var validation = await _createSectionValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _tables.CreateBranchSectionAsync(cafeId, branchId, request, ct);
return BranchOpResult(result);
}
[HttpPatch("sections/{sectionId}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> PatchSection(
string cafeId,
string branchId,
string sectionId,
[FromBody] PatchTableSectionRequest request,
ITenantContext tenant,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
var validation = await _patchSectionValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _tables.PatchBranchSectionAsync(cafeId, branchId, sectionId, request, ct);
return BranchOpResult(result);
}
[HttpDelete("sections/{sectionId}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> DeleteSection(
string cafeId,
string branchId,
string sectionId,
ITenantContext tenant,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
return Forbid();
var result = await _tables.DeleteBranchSectionAsync(cafeId, branchId, sectionId, ct);
return BranchOpResult(result);
}
private IActionResult BranchOpResult<T>(BranchTableOperationResult<T> result)
{
if (result.Success && result.Data is not null)
return Ok(new ApiResponse<T>(true, result.Data));
var code = result.ErrorCode ?? "REQUEST_FAILED";
var status = code is "TABLE_HAS_OPEN_ORDER" or "TABLE_SECTION_HAS_TABLES"
? StatusCodes.Status409Conflict
: StatusCodes.Status400BadRequest;
return StatusCode(status,
new ApiResponse<object>(false, null, new ApiError(code, result.Message ?? code)));
}
}
@@ -0,0 +1,306 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Branches;
using Meezi.API.Services;
using Meezi.Core.Constants;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/branches")]
public class BranchesController : CafeApiControllerBase
{
private const string PlanLimitMessage =
"Branch limit reached for your plan. Upgrade to Pro or Business to add more branches.";
private readonly AppDbContext _db;
private readonly IBranchLifecycleService _lifecycle;
private readonly IValidator<CreateBranchRequest> _createValidator;
private readonly IValidator<PatchBranchRequest> _patchValidator;
public BranchesController(
AppDbContext db,
IBranchLifecycleService lifecycle,
IValidator<CreateBranchRequest> createValidator,
IValidator<PatchBranchRequest> patchValidator)
{
_db = db;
_lifecycle = lifecycle;
_createValidator = createValidator;
_patchValidator = patchValidator;
}
[HttpGet]
public async Task<IActionResult> List(
string cafeId,
ITenantContext tenant,
[FromQuery] bool activeOnly = false,
[FromQuery] bool includePendingDeletion = false,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var now = DateTime.UtcNow;
List<Branch> branches;
if (includePendingDeletion)
{
branches = await _db.Branches
.IgnoreQueryFilters()
.Where(b => b.CafeId == cafeId
&& (b.DeletedAt == null
|| (b.ScheduledPermanentDeleteAt != null && b.ScheduledPermanentDeleteAt > now)))
.OrderBy(b => b.DeletedAt == null ? 0 : 1)
.ThenBy(b => b.Name)
.ToListAsync(ct);
}
else
{
var query = _db.Branches.Where(b => b.CafeId == cafeId);
if (activeOnly)
query = query.Where(b => b.IsActive);
branches = await query.OrderBy(b => b.Name).ToListAsync(ct);
}
var branchIds = branches.Select(b => b.Id).ToList();
var managers = await _db.Employees
.AsNoTracking()
.IgnoreQueryFilters()
.Where(e => e.CafeId == cafeId && e.BranchId != null && branchIds.Contains(e.BranchId!)
&& e.Role == EmployeeRole.Manager && e.DeletedAt == null)
.ToListAsync(ct);
var managerByBranch = managers
.GroupBy(e => e.BranchId!)
.ToDictionary(g => g.Key, g => g.First());
var data = branches.Select(b =>
{
managerByBranch.TryGetValue(b.Id, out var mgr);
return ToDto(b, mgr?.Phone, mgr?.Name, now);
}).ToList();
return Ok(new ApiResponse<IReadOnlyList<BranchDto>>(true, data));
}
[HttpPost]
public async Task<IActionResult> Create(
string cafeId,
[FromBody] CreateBranchRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var loginPhone = PhoneNormalizer.Normalize(request.LoginPhone);
var phoneTaken = await _db.Employees.AnyAsync(
e => e.CafeId == cafeId && e.Phone == loginPhone && e.DeletedAt == null, ct);
if (phoneTaken)
{
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("PHONE_ALREADY_REGISTERED",
"This mobile number is already used for login at this cafe.")));
}
var tier = tenant.PlanTier ?? PlanTier.Free;
var count = await _db.Branches.CountAsync(b => b.CafeId == cafeId, ct);
var max = PlanLimits.MaxBranches(tier);
if (count >= max)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", PlanLimitMessage)));
var now = DateTime.UtcNow;
var branchId = $"branch_{Guid.NewGuid():N}"[..24];
var managerName = string.IsNullOrWhiteSpace(request.ManagerName)
? request.Name.Trim()
: request.ManagerName.Trim();
var branch = new Branch
{
Id = branchId,
CafeId = cafeId,
Name = request.Name.Trim(),
Address = request.Address?.Trim(),
City = request.City?.Trim(),
Phone = request.Phone?.Trim(),
IsActive = true,
CreatedAt = now,
UpdatedAt = now
};
var employee = new Employee
{
Id = $"emp_{Guid.NewGuid():N}"[..24],
CafeId = cafeId,
BranchId = branchId,
Name = managerName,
Phone = loginPhone,
Role = EmployeeRole.Manager,
CreatedAt = now
};
_db.Branches.Add(branch);
_db.Employees.Add(employee);
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<BranchDto>(true, ToDto(branch, loginPhone, managerName, now)));
}
[HttpPatch("{branchId}")]
public async Task<IActionResult> Patch(
string cafeId,
string branchId,
[FromBody] PatchBranchRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
var validation = await _patchValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var branch = await _db.Branches.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
if (branch is null) return NotFoundError();
if (request.TaxRate.HasValue)
{
var cafe = await _db.Cafes.AsNoTracking().FirstAsync(c => c.Id == cafeId, ct);
if (!cafe.AllowBranchTaxOverride)
{
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("TAX_OVERRIDE_NOT_ALLOWED",
"تغییر نرخ مالیات شعبه توسط مالک غیرفعال شده است")));
}
}
if (request.Name is not null) branch.Name = request.Name.Trim();
if (request.Address is not null) branch.Address = request.Address.Trim();
if (request.City is not null) branch.City = request.City.Trim();
if (request.Phone is not null) branch.Phone = request.Phone.Trim();
if (request.IsActive.HasValue) branch.IsActive = request.IsActive.Value;
if (request.LogoUrl is not null) branch.LogoUrl = string.IsNullOrWhiteSpace(request.LogoUrl) ? null : request.LogoUrl.Trim();
if (request.WelcomeText is not null) branch.WelcomeText = string.IsNullOrWhiteSpace(request.WelcomeText) ? null : request.WelcomeText.Trim();
if (request.AccentColor is not null) branch.AccentColor = string.IsNullOrWhiteSpace(request.AccentColor) ? null : request.AccentColor.Trim();
if (request.WifiPassword is not null) branch.WifiPassword = string.IsNullOrWhiteSpace(request.WifiPassword) ? null : request.WifiPassword.Trim();
if (request.TaxRate.HasValue) branch.TaxRate = request.TaxRate.Value;
branch.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
var mgr = await _db.Employees.AsNoTracking()
.FirstOrDefaultAsync(e => e.CafeId == cafeId && e.BranchId == branchId
&& e.Role == EmployeeRole.Manager && e.DeletedAt == null, ct);
return Ok(new ApiResponse<BranchDto>(true, ToDto(branch, mgr?.Phone, mgr?.Name, DateTime.UtcNow)));
}
[HttpDelete("{branchId}")]
public async Task<IActionResult> Delete(
string cafeId,
string branchId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
var (ok, code, message) = await _lifecycle.ScheduleDeletionAsync(cafeId, branchId, ct);
if (!ok)
{
return code switch
{
"NOT_FOUND" => NotFoundError(),
"LAST_BRANCH" => BadRequest(new ApiResponse<object>(false, null,
new ApiError(code, message ?? "Cannot delete the last branch."))),
_ => BadRequest(new ApiResponse<object>(false, null,
new ApiError(code ?? "DELETE_FAILED", message ?? "Could not delete branch.")))
};
}
var branch = await _db.Branches
.IgnoreQueryFilters()
.FirstAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
var mgr = await _db.Employees.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(e => e.CafeId == cafeId && e.BranchId == branchId
&& e.Role == EmployeeRole.Manager && e.DeletedAt == branch.DeletedAt, ct);
return Ok(new ApiResponse<BranchDto>(true,
ToDto(branch, mgr?.Phone, mgr?.Name, DateTime.UtcNow)));
}
[HttpPost("{branchId}/restore")]
public async Task<IActionResult> Restore(
string cafeId,
string branchId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
var (ok, code, message) = await _lifecycle.RestoreAsync(cafeId, branchId, ct);
if (!ok)
{
return code switch
{
"NOT_FOUND" => NotFoundError(),
"PURGE_EXPIRED" => BadRequest(new ApiResponse<object>(false, null,
new ApiError(code, message ?? "Recovery period has ended."))),
_ => BadRequest(new ApiResponse<object>(false, null,
new ApiError(code ?? "RESTORE_FAILED", message ?? "Could not restore branch.")))
};
}
var branch = await _db.Branches.FirstAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
var mgr = await _db.Employees.AsNoTracking()
.FirstOrDefaultAsync(e => e.CafeId == cafeId && e.BranchId == branchId
&& e.Role == EmployeeRole.Manager && e.DeletedAt == null, ct);
return Ok(new ApiResponse<BranchDto>(true, ToDto(branch, mgr?.Phone, mgr?.Name, DateTime.UtcNow)));
}
private static BranchDto ToDto(Branch b, string? loginPhone, string? managerName, DateTime utcNow)
{
var pending = b.DeletedAt is not null
&& b.ScheduledPermanentDeleteAt is not null
&& b.ScheduledPermanentDeleteAt > utcNow;
int? daysLeft = pending
? Math.Max(0, (int)Math.Ceiling((b.ScheduledPermanentDeleteAt!.Value - utcNow).TotalDays))
: null;
return new BranchDto(
b.Id,
b.Name,
b.Address,
b.City,
b.Phone,
b.IsActive && !pending,
loginPhone,
managerName,
pending,
b.DeletedAt,
b.ScheduledPermanentDeleteAt,
daysLeft);
}
}
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Authorize]
[ApiController]
public abstract class CafeApiControllerBase : ControllerBase
{
protected IActionResult? EnsureCafeAccess(string routeCafeId, ITenantContext tenant)
{
if (string.IsNullOrEmpty(tenant.CafeId) || tenant.CafeId != routeCafeId)
return StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "You do not have access to this cafe.")));
return null;
}
protected IActionResult? EnsureOwner(ITenantContext tenant)
{
if (tenant.Role == EmployeeRole.Owner)
return null;
return StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null,
new ApiError("OWNER_REQUIRED", "Only the cafe owner can perform this action.")));
}
protected static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation)
{
var first = validation.Errors.First();
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
}
protected IActionResult NotFoundError(string message = "Resource not found.") =>
NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", message)));
}
@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Discover;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Infrastructure.Discover;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Controllers;
[Authorize]
[Route("api/cafes/{cafeId}/discover-profile")]
public class CafeDiscoverProfileController : CafeApiControllerBase
{
private readonly AppDbContext _db;
public CafeDiscoverProfileController(AppDbContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> Get(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied)
return denied;
var cafe = await _db.Cafes.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken: ct);
if (cafe is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
var profile = CafeDiscoverProfileSerializer.Deserialize(cafe.DiscoverProfileJson);
return Ok(new ApiResponse<CafeDiscoverProfileDto>(true, CafeDiscoverProfileMapping.ToDto(profile)));
}
[HttpPut]
public async Task<IActionResult> Put(
string cafeId,
[FromBody] UpsertCafeDiscoverProfileRequest request,
ITenantContext tenant,
IPlatformCatalogService catalog,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied)
return denied;
var planTier = tenant.PlanTier ?? PlanTier.Free;
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "discover_profile", ct))
{
return StatusCode(
StatusCodes.Status403Forbidden,
new ApiResponse<object>(
false,
null,
new ApiError("PLAN_FEATURE_DISABLED", "Discover profile is not included in your plan. Upgrade to enable it.")));
}
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken: ct);
if (cafe is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
var profile = CafeDiscoverProfileMapping.FromRequest(request);
cafe.DiscoverProfileJson = CafeDiscoverProfileSerializer.Serialize(profile);
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<CafeDiscoverProfileDto>(true, CafeDiscoverProfileMapping.ToDto(profile)));
}
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/platform")]
public class CafePlatformController : CafeApiControllerBase
{
private readonly IPlatformCatalogService _catalog;
public CafePlatformController(IPlatformCatalogService catalog)
{
_catalog = catalog;
}
[HttpGet("features")]
public async Task<IActionResult> GetFeatures(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (tenant.PlanTier is null)
return Ok(new ApiResponse<object>(true, new Dictionary<string, bool>()));
var features = await _catalog.GetEffectiveFeaturesForCafeAsync(
cafeId,
tenant.PlanTier.Value,
cancellationToken);
return Ok(new ApiResponse<object>(true, features));
}
[HttpGet("plans")]
public async Task<IActionResult> GetPublicPlans(CancellationToken cancellationToken)
{
var plans = await _catalog.GetPlansAsync(cancellationToken);
return Ok(new ApiResponse<object>(true, plans));
}
}
@@ -0,0 +1,219 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Core.Discover;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Controllers;
/// <summary>
/// Allows cafe owners to manage their public-facing profile:
/// description, gallery (up to 8 photos), working hours, instagram, website.
/// Route: /api/cafes/{cafeId}/public-profile
/// </summary>
[Authorize]
[Route("api/cafes/{cafeId}/public-profile")]
public class CafePublicProfileController : CafeApiControllerBase
{
private const int MaxGalleryPhotos = 8;
private readonly AppDbContext _db;
private readonly IMediaStorageService _media;
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public CafePublicProfileController(AppDbContext db, IMediaStorageService media)
{
_db = db;
_media = media;
}
// ── GET ──────────────────────────────────────────────────────────────────
[HttpGet]
public async Task<IActionResult> Get(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var cafe = await _db.Cafes.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null)
return NotFound(Fail("NOT_FOUND", "Cafe not found."));
var hours = Deserialize<WorkingHoursSchedule>(cafe.WorkingHoursJson);
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
return Ok(new ApiResponse<CafeProfileEditDto>(true, new CafeProfileEditDto(
cafe.Description,
gallery,
cafe.InstagramHandle,
cafe.WebsiteUrl,
ToHoursDto(hours))));
}
// ── PUT (description / social / hours) ───────────────────────────────────
[HttpPut]
public async Task<IActionResult> Put(
string cafeId,
[FromBody] UpdateCafePublicProfileRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null)
return NotFound(Fail("NOT_FOUND", "Cafe not found."));
// Description
if (request.Description is not null)
cafe.Description = request.Description.Trim();
// Instagram handle — strip leading @ if present
if (request.InstagramHandle is not null)
cafe.InstagramHandle = request.InstagramHandle.TrimStart('@').Trim().ToLowerInvariant();
// Website URL
if (request.WebsiteUrl is not null)
cafe.WebsiteUrl = request.WebsiteUrl.Trim();
// Working hours
if (request.WorkingHours is not null)
cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts);
await _db.SaveChangesAsync(ct);
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
var hours = Deserialize<WorkingHoursSchedule>(cafe.WorkingHoursJson);
return Ok(new ApiResponse<CafeProfileEditDto>(true, new CafeProfileEditDto(
cafe.Description,
gallery,
cafe.InstagramHandle,
cafe.WebsiteUrl,
ToHoursDto(hours))));
}
// ── POST gallery/upload ───────────────────────────────────────────────────
[HttpPost("gallery")]
[RequestSizeLimit(8 * 1024 * 1024)]
public async Task<IActionResult> UploadGalleryPhoto(
string cafeId,
[FromForm] IFormFile photo,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (photo is null || photo.Length == 0)
return BadRequest(Fail("NO_FILE", "No photo provided."));
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null)
return NotFound(Fail("NOT_FOUND", "Cafe not found."));
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
if (gallery.Count >= MaxGalleryPhotos)
return BadRequest(Fail("GALLERY_FULL", $"Maximum {MaxGalleryPhotos} gallery photos allowed. Remove one first."));
var url = await _media.SaveCafeGalleryPhotoAsync(cafeId, photo, ct);
if (url is null)
return StatusCode(422, Fail("UPLOAD_FAILED", "Could not process image. Check format and size (max 5 MB, JPEG/PNG/WebP)."));
gallery.Add(url);
cafe.GalleryJson = JsonSerializer.Serialize(gallery, _jsonOpts);
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<GalleryDto>(true, new GalleryDto(gallery)));
}
// ── DELETE gallery photo ──────────────────────────────────────────────────
[HttpDelete("gallery")]
public async Task<IActionResult> RemoveGalleryPhoto(
string cafeId,
[FromQuery] string url,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (string.IsNullOrWhiteSpace(url))
return BadRequest(Fail("NO_URL", "Provide ?url= of the photo to remove."));
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null)
return NotFound(Fail("NOT_FOUND", "Cafe not found."));
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
var removed = gallery.Remove(url);
if (!removed)
return NotFound(Fail("NOT_IN_GALLERY", "URL not found in gallery."));
cafe.GalleryJson = JsonSerializer.Serialize(gallery, _jsonOpts);
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<GalleryDto>(true, new GalleryDto(gallery)));
}
// ── helpers ───────────────────────────────────────────────────────────────
private static ApiResponse<object> Fail(string code, string message) =>
new(false, null, new ApiError(code, message));
private static T? Deserialize<T>(string? json) where T : class
{
if (string.IsNullOrWhiteSpace(json)) return null;
try { return JsonSerializer.Deserialize<T>(json, _jsonOpts); }
catch { return null; }
}
private static WorkingHoursPublicDto? ToHoursDto(WorkingHoursSchedule? h)
{
if (h is null) return null;
DaySchedulePublicDto? M(DaySchedule? d) =>
d is null ? null : new DaySchedulePublicDto(d.IsOpen, d.Open, d.Close);
return new WorkingHoursPublicDto(M(h.Sat), M(h.Sun), M(h.Mon), M(h.Tue), M(h.Wed), M(h.Thu), M(h.Fri));
}
private static WorkingHoursSchedule ToHoursSchedule(WorkingHoursPublicDto dto)
{
static DaySchedule? M(DaySchedulePublicDto? d) =>
d is null ? null : new DaySchedule { IsOpen = d.IsOpen, Open = d.Open, Close = d.Close };
return new WorkingHoursSchedule
{
Sat = M(dto.Sat), Sun = M(dto.Sun), Mon = M(dto.Mon),
Tue = M(dto.Tue), Wed = M(dto.Wed), Thu = M(dto.Thu), Fri = M(dto.Fri)
};
}
}
// ── Request / response DTOs ───────────────────────────────────────────────────
public record UpdateCafePublicProfileRequest(
string? Description,
string? InstagramHandle,
string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours);
public record CafeProfileEditDto(
string? Description,
IReadOnlyList<string> GalleryUrls,
string? InstagramHandle,
string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours);
public record GalleryDto(IReadOnlyList<string> GalleryUrls);
@@ -0,0 +1,69 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/reviews")]
public class CafeReviewsController : CafeApiControllerBase
{
private readonly IReviewService _reviews;
private readonly IValidator<ReplyCafeReviewRequest> _replyValidator;
public CafeReviewsController(IReviewService reviews, IValidator<ReplyCafeReviewRequest> replyValidator)
{
_reviews = reviews;
_replyValidator = replyValidator;
}
[HttpGet]
public async Task<IActionResult> List(
string cafeId,
ITenantContext tenant,
CancellationToken ct,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _reviews.GetReviewsAsync(cafeId, page, pageSize, publicOnly: false, ct);
return Ok(new ApiResponse<IReadOnlyList<CafeReviewDto>>(true, data));
}
[HttpPatch("{reviewId}/reply")]
public async Task<IActionResult> Reply(
string cafeId,
string reviewId,
[FromBody] ReplyCafeReviewRequest request,
ITenantContext tenant,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _replyValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var data = await _reviews.ReplyReviewAsync(cafeId, reviewId, request.Reply, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<CafeReviewDto>(true, data));
}
[HttpPatch("{reviewId}/visibility")]
public async Task<IActionResult> SetVisibility(
string cafeId,
string reviewId,
[FromBody] HideCafeReviewRequest request,
ITenantContext tenant,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _reviews.SetHiddenAsync(cafeId, reviewId, request.IsHidden, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<CafeReviewDto>(true, data));
}
}
@@ -0,0 +1,94 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Cafes;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Branding;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/settings")]
public class CafeSettingsController : CafeApiControllerBase
{
private readonly AppDbContext _db;
private readonly IValidator<PatchCafeSettingsRequest> _validator;
public CafeSettingsController(AppDbContext db, IValidator<PatchCafeSettingsRequest> validator)
{
_db = db;
_validator = validator;
}
[HttpGet]
public async Task<IActionResult> Get(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null) return NotFoundError();
return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe)));
}
[HttpPatch]
public async Task<IActionResult> Patch(
string cafeId,
[FromBody] PatchCafeSettingsRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
if (request.DefaultTaxRate is not null || request.AllowBranchTaxOverride is not null)
{
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
}
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null) return NotFoundError();
if (request.Name is not null) cafe.Name = request.Name.Trim();
if (request.Phone is not null) cafe.Phone = request.Phone.Trim();
if (request.Address is not null) cafe.Address = request.Address.Trim();
if (request.City is not null) cafe.City = request.City.Trim();
if (request.Description is not null) cafe.Description = request.Description.Trim();
if (request.LogoUrl is not null) cafe.LogoUrl = request.LogoUrl.Trim();
if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim();
if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim();
if (request.Theme is not null)
cafe.ThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
if (request.DefaultTaxRate is decimal taxRate)
cafe.DefaultTaxRate = taxRate;
if (request.AllowBranchTaxOverride is bool allowTax)
cafe.AllowBranchTaxOverride = allowTax;
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe)));
}
private static CafeSettingsDto ToDto(Core.Entities.Cafe cafe) => new(
cafe.Id,
cafe.Name,
cafe.Slug,
cafe.Phone,
cafe.Address,
cafe.City,
cafe.Description,
cafe.LogoUrl,
cafe.CoverImageUrl,
cafe.SnappfoodVendorId,
cafe.PlanTier.ToString(),
cafe.PlanExpiresAt,
CafeThemeMapping.FromJson(cafe.ThemeJson),
cafe.DefaultTaxRate,
cafe.AllowBranchTaxOverride);
}
@@ -0,0 +1,82 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Meezi.API.Models.Auth;
using Meezi.API.Services;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/auth/customer")]
public class ConsumerAuthController : ControllerBase
{
private readonly IConsumerAuthService _auth;
private readonly IValidator<SendOtpRequest> _sendValidator;
private readonly IValidator<VerifyOtpRequest> _verifyValidator;
public ConsumerAuthController(
IConsumerAuthService auth,
IValidator<SendOtpRequest> sendValidator,
IValidator<VerifyOtpRequest> verifyValidator)
{
_auth = auth;
_sendValidator = sendValidator;
_verifyValidator = verifyValidator;
}
[HttpPost("send-otp")]
[EnableRateLimiting("auth-otp")]
public async Task<IActionResult> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct)
{
var validation = await _sendValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return ValidationBadRequest(validation);
var (success, data, code, message) = await _auth.SendOtpAsync(request, ct);
if (!success)
return AuthError(code, message);
return Ok(new ApiResponse<SendOtpResponse>(true, data));
}
[HttpPost("verify-otp")]
[EnableRateLimiting("auth-otp")]
public async Task<IActionResult> VerifyOtp([FromBody] VerifyOtpRequest request, CancellationToken ct)
{
var validation = await _verifyValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return ValidationBadRequest(validation);
var (success, data, code, message) = await _auth.VerifyOtpAsync(request, ct);
if (!success)
return AuthError(code, message);
return Ok(new ApiResponse<object>(true, data));
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken ct)
{
var (success, data, code, message) = await _auth.RefreshAsync(request, ct);
if (!success)
return AuthError(code, message);
return Ok(new ApiResponse<object>(true, data));
}
private IActionResult ValidationBadRequest(FluentValidation.Results.ValidationResult validation)
{
var first = validation.Errors[0];
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
private IActionResult AuthError(string? code, string? message) =>
code switch
{
"RATE_LIMITED" => StatusCode(429, new ApiResponse<object>(false, null, new ApiError(code, message ?? "Rate limited."))),
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? "Not found."))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "AUTH_FAILED", message ?? "Authentication failed.")))
};
}
@@ -0,0 +1,102 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Crm;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/coupons")]
public class CouponsController : CafeApiControllerBase
{
private readonly ICouponService _couponService;
private readonly IValidator<CreateCouponRequest> _createValidator;
public CouponsController(
ICouponService couponService,
IValidator<CreateCouponRequest> createValidator)
{
_couponService = couponService;
_createValidator = createValidator;
}
[HttpGet]
public async Task<IActionResult> GetAll(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _couponService.GetAllAsync(cafeId, cancellationToken);
return Ok(new ApiResponse<IReadOnlyList<CouponDto>>(true, data));
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _couponService.GetAsync(cafeId, id, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<CouponDto>(true, data));
}
[HttpPost("validate")]
public async Task<IActionResult> Validate(
string cafeId,
[FromBody] ValidateCouponRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var (data, error) = await _couponService.ValidateAsync(cafeId, request, cancellationToken);
if (error is not null)
return BadRequest(new ApiResponse<object>(false, null, error));
return Ok(new ApiResponse<ValidateCouponResult>(true, data));
}
[HttpPost]
public async Task<IActionResult> Create(
string cafeId,
[FromBody] CreateCouponRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _couponService.CreateAsync(cafeId, request, cancellationToken);
if (data is null)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("DUPLICATE_CODE", "Coupon code already exists.")));
return Ok(new ApiResponse<CouponDto>(true, data));
}
[HttpPatch("{id}")]
public async Task<IActionResult> Update(
string cafeId,
string id,
[FromBody] UpdateCouponRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _couponService.UpdateAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<CouponDto>(true, data));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _couponService.DeleteAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
}
@@ -0,0 +1,36 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Consumer;
using Meezi.API.Services;
using Meezi.Core.Constants;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[ApiController]
[Authorize]
[Route("api/customers/me")]
public class CustomerMeController : ControllerBase
{
private readonly IConsumerOrdersService _orders;
public CustomerMeController(IConsumerOrdersService orders) => _orders = orders;
[HttpGet("orders")]
public async Task<IActionResult> GetOrders(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
if (User.FindFirst(MeeziClaimTypes.Actor)?.Value != MeeziActorKinds.Consumer)
return Forbid();
var phone = User.FindFirst(MeeziClaimTypes.Phone)?.Value;
if (string.IsNullOrEmpty(phone))
return Unauthorized(new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "Phone claim missing.")));
var data = await _orders.GetMyOrdersAsync(phone, page, pageSize, ct);
return Ok(new ApiResponse<IReadOnlyList<ConsumerOrderHistoryDto>>(true, data));
}
}
@@ -0,0 +1,106 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Crm;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/customers")]
public class CustomersController : CafeApiControllerBase
{
private readonly ICustomerService _customerService;
private readonly IValidator<CreateCustomerRequest> _createValidator;
private readonly IValidator<UpdateCustomerRequest> _updateValidator;
public CustomersController(
ICustomerService customerService,
IValidator<CreateCustomerRequest> createValidator,
IValidator<UpdateCustomerRequest> updateValidator)
{
_customerService = customerService;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
[HttpGet]
public async Task<IActionResult> Search(
string cafeId,
[FromQuery] string? q,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _customerService.SearchAsync(cafeId, q, cancellationToken);
return Ok(new ApiResponse<IReadOnlyList<CustomerDto>>(true, data));
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _customerService.GetAsync(cafeId, id, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<CustomerDto>(true, data));
}
[HttpPost]
public async Task<IActionResult> Create(
string cafeId,
[FromBody] CreateCustomerRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _customerService.CreateAsync(cafeId, request, cancellationToken);
if (data is null)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("DUPLICATE_PHONE", "A customer with this phone already exists.")));
return Ok(new ApiResponse<CustomerDto>(true, data));
}
[HttpPatch("{id}")]
public async Task<IActionResult> Update(
string cafeId,
string id,
[FromBody] UpdateCustomerRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _updateValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var existing = await _customerService.GetAsync(cafeId, id, cancellationToken);
if (existing is null) return NotFoundError();
var data = await _customerService.UpdateAsync(cafeId, id, request, cancellationToken);
if (data is null)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("DUPLICATE_PHONE", "A customer with this phone already exists.")));
return Ok(new ApiResponse<CustomerDto>(true, data));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _customerService.DeleteAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
}
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Services.Delivery;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/reports/delivery")]
public class DeliveryReportsController : CafeApiControllerBase
{
private readonly IDeliveryFinanceReportService _reports;
public DeliveryReportsController(IDeliveryFinanceReportService reports) => _reports = reports;
[HttpGet("revenue")]
public async Task<IActionResult> RevenueByPlatform(
string cafeId,
ITenantContext tenant,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var utcTo = to ?? DateTime.UtcNow;
var utcFrom = from ?? utcTo.AddDays(-30);
var data = await _reports.GetRevenueByPlatformAsync(cafeId, utcFrom, utcTo, ct);
return Ok(new ApiResponse<object>(true, data));
}
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Configuration;
using Meezi.API.Services.Delivery;
using Meezi.Core.Enums;
using Meezi.Shared;
using Microsoft.Extensions.Options;
namespace Meezi.API.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/webhooks/digikala")]
public class DigikalaWebhookController : ControllerBase
{
private readonly IDeliveryWebhookIngressService _ingress;
private readonly DeliveryPlatformsOptions _options;
public DigikalaWebhookController(
IDeliveryWebhookIngressService ingress,
IOptions<DeliveryPlatformsOptions> options)
{
_ingress = ingress;
_options = options.Value;
}
[HttpPost]
public async Task<IActionResult> Receive(CancellationToken ct)
{
if (!_options.Digikala.Enabled)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Digikala webhook disabled.")));
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Digikala-Signature"].FirstOrDefault()
?? Request.Headers["X-Hub-Signature-256"].FirstOrDefault();
var result = await _ingress.ReceiveAsync(DeliveryPlatform.Digikala, rawBody, signature, ct);
if (!result.Accepted)
{
var status = result.ErrorCode == "UNAUTHORIZED"
? StatusCodes.Status401Unauthorized
: StatusCodes.Status400BadRequest;
return StatusCode(status, new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode ?? "ERROR", result.Message ?? "Failed.")));
}
return Ok(new ApiResponse<object>(true, new { received = true, logId = result.WebhookLogId }));
}
}
@@ -0,0 +1,132 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Expenses;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/expenses")]
public class ExpensesController : CafeApiControllerBase
{
private readonly IExpenseService _expenses;
private readonly IValidator<CreateExpenseRequest> _createValidator;
public ExpensesController(
IExpenseService expenses,
IValidator<CreateExpenseRequest> createValidator)
{
_expenses = expenses;
_createValidator = createValidator;
}
[HttpPost]
public async Task<IActionResult> Create(
string cafeId,
[FromBody] CreateExpenseRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status401Unauthorized,
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
if (!CanLogExpense(tenant.Role))
return StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "You cannot log expenses.")));
var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _expenses.CreateExpenseAsync(cafeId, request, tenant.UserId, ct);
return ExpenseResult(result, StatusCodes.Status201Created);
}
[HttpGet]
public async Task<IActionResult> List(
string cafeId,
[FromQuery] string branchId,
[FromQuery] string from,
[FromQuery] string to,
ITenantContext tenant,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (string.IsNullOrWhiteSpace(branchId))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
if (!DateOnly.TryParse(from, out var fromDate) || !DateOnly.TryParse(to, out var toDate))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
if (fromDate > toDate)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "from must be on or before to.", "from")));
var data = await _expenses.GetExpensesAsync(cafeId, branchId, fromDate, toDate, page, pageSize, ct);
return Ok(new PagedApiResponse<ExpenseDto>(
true,
data.Items,
new PagedMeta(data.Total, page, pageSize)));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!CanDeleteExpense(tenant.Role))
return StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Only managers can delete expenses.")));
var result = await _expenses.DeleteExpenseAsync(cafeId, id, ct);
if (!result.Success)
{
return result.ErrorCode switch
{
"NOT_FOUND" => NotFoundError("Expense not found."),
_ => BadRequest(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode ?? "ERROR", "Delete failed.")))
};
}
return Ok(new ApiResponse<object>(true, null));
}
private static bool CanLogExpense(EmployeeRole? role) =>
role is EmployeeRole.Owner or EmployeeRole.Manager or EmployeeRole.Cashier;
private static bool CanDeleteExpense(EmployeeRole? role) =>
role is EmployeeRole.Owner or EmployeeRole.Manager;
private IActionResult ExpenseResult(ExpenseServiceResult<ExpenseDto> result, int successStatus = StatusCodes.Status200OK)
{
if (result.Success)
return StatusCode(successStatus, new ApiResponse<ExpenseDto>(true, result.Data));
return result.ErrorCode switch
{
"BRANCH_NOT_FOUND" => NotFound(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode, "Branch not found.", result.Field))),
"SHIFT_NOT_FOUND" => NotFound(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode, "Shift not found.", result.Field))),
"SHIFT_BRANCH_MISMATCH" => BadRequest(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode, "Shift does not belong to this branch.", result.Field))),
"SHIFT_ALREADY_CLOSED" => BadRequest(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode, "Shift is already closed.", result.Field))),
_ => BadRequest(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode ?? "ERROR", "Could not create expense.", result.Field)))
};
}
}
+221
View File
@@ -0,0 +1,221 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Hr;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}")]
public class HrController : CafeApiControllerBase
{
private readonly IHrService _hr;
private readonly IValidator<CreateLeaveRequest> _leaveValidator;
private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
private readonly IValidator<CreateSalaryRequest> _salaryValidator;
public HrController(
IHrService hr,
IValidator<CreateLeaveRequest> leaveValidator,
IValidator<ReviewLeaveRequest> reviewValidator,
IValidator<CreateSalaryRequest> salaryValidator)
{
_hr = hr;
_leaveValidator = leaveValidator;
_reviewValidator = reviewValidator;
_salaryValidator = salaryValidator;
}
[HttpGet("employees")]
public async Task<IActionResult> GetEmployees(
string cafeId,
ITenantContext tenant,
[FromQuery] string? branchId = null,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _hr.GetEmployeesAsync(cafeId, branchId, ct);
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
}
[HttpGet("employees/{employeeId}")]
public async Task<IActionResult> GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _hr.GetEmployeeAsync(cafeId, employeeId, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<EmployeeSummaryDto>(true, data));
}
[HttpGet("employees/{employeeId}/shift/today")]
public async Task<IActionResult> GetTodayShift(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden;
var data = await _hr.GetTodayShiftAsync(cafeId, employeeId, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<TodayShiftDto>(true, data));
}
[HttpPost("employees/{employeeId}/attendance/clock-in")]
public async Task<IActionResult> ClockIn(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden;
var data = await _hr.ClockInAsync(cafeId, employeeId, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<AttendanceDto>(true, data));
}
[HttpPost("employees/{employeeId}/attendance/clock-out")]
public async Task<IActionResult> ClockOut(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden;
var data = await _hr.ClockOutAsync(cafeId, employeeId, ct);
if (data is null) return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Clock-in required before clock-out.")));
return Ok(new ApiResponse<AttendanceDto>(true, data));
}
[HttpGet("attendance")]
public async Task<IActionResult> GetAttendance(
string cafeId,
[FromQuery] string? employeeId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _hr.GetAttendanceAsync(cafeId, employeeId, from, to, ct);
return Ok(new ApiResponse<IReadOnlyList<AttendanceDto>>(true, data));
}
[HttpGet("employees/{employeeId}/shifts")]
public async Task<IActionResult> GetShifts(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _hr.GetShiftsAsync(cafeId, employeeId, ct);
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
}
[HttpPut("employees/{employeeId}/shifts")]
public async Task<IActionResult> UpsertShifts(
string cafeId,
string employeeId,
[FromBody] UpsertShiftsRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var data = await _hr.UpsertShiftsAsync(cafeId, employeeId, request, ct);
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
}
[HttpGet("leave-requests")]
public async Task<IActionResult> GetLeaveRequests(
string cafeId,
[FromQuery] LeaveStatus? status,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _hr.GetLeaveRequestsAsync(cafeId, status, ct);
return Ok(new ApiResponse<IReadOnlyList<LeaveRequestDto>>(true, data));
}
[HttpPost("employees/{employeeId}/leave-requests")]
public async Task<IActionResult> CreateLeave(
string cafeId,
string employeeId,
[FromBody] CreateLeaveRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden;
var validation = await _leaveValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _hr.CreateLeaveRequestAsync(cafeId, employeeId, request, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<LeaveRequestDto>(true, data));
}
[HttpPatch("leave-requests/{leaveId}/status")]
public async Task<IActionResult> ReviewLeave(
string cafeId,
string leaveId,
[FromBody] ReviewLeaveRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var validation = await _reviewValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _hr.ReviewLeaveRequestAsync(cafeId, leaveId, tenant.UserId!, request, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<LeaveRequestDto>(true, data));
}
[HttpGet("salaries")]
public async Task<IActionResult> GetSalaries(
string cafeId,
[FromQuery] string? monthYear,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _hr.GetSalariesAsync(cafeId, monthYear, ct);
return Ok(new ApiResponse<IReadOnlyList<EmployeeSalaryDto>>(true, data));
}
[HttpPost("salaries")]
public async Task<IActionResult> CreateSalary(
string cafeId,
[FromBody] CreateSalaryRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var validation = await _salaryValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _hr.CreateSalaryAsync(cafeId, request, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
}
[HttpPatch("salaries/{salaryId}/paid")]
public async Task<IActionResult> MarkPaid(string cafeId, string salaryId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var data = await _hr.MarkSalaryPaidAsync(cafeId, salaryId, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
}
private static IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant)
{
if (tenant.UserId == employeeId) return null;
return EnsureManager(tenant);
}
private static IActionResult? EnsureManager(ITenantContext tenant)
{
if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager)
return null;
return new ObjectResult(new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Manager access required.")))
{
StatusCode = StatusCodes.Status403Forbidden
};
}
}
@@ -0,0 +1,140 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/inventory")]
public class InventoryController : CafeApiControllerBase
{
private readonly IInventoryService _inventory;
public InventoryController(IInventoryService inventory) => _inventory = inventory;
[HttpGet("ingredients")]
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _inventory.ListAsync(cafeId, ct);
return Ok(new ApiResponse<object>(true, data));
}
[HttpGet("low-stock")]
public async Task<IActionResult> LowStock(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _inventory.LowStockAsync(cafeId, ct);
return Ok(new ApiResponse<object>(true, data));
}
[HttpPost("ingredients")]
public async Task<IActionResult> Create(
string cafeId,
[FromBody] CreateIngredientRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required.")));
if (request.QuantityOnHand > 0 && request.TotalPaidToman > 0 && string.IsNullOrWhiteSpace(request.BranchId))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("BRANCH_ID_REQUIRED", "Branch is required when recording purchase cost.")));
var created = await _inventory.CreateAsync(cafeId, request, ct);
return Ok(new ApiResponse<object>(true, created));
}
[HttpPatch("ingredients/{ingredientId}")]
public async Task<IActionResult> Update(
string cafeId,
string ingredientId,
[FromBody] UpdateIngredientRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var updated = await _inventory.UpdateAsync(cafeId, ingredientId, request, ct);
if (updated is null) return NotFoundError();
return Ok(new ApiResponse<object>(true, updated));
}
[HttpPost("ingredients/{ingredientId}/adjust")]
public async Task<IActionResult> Adjust(
string cafeId,
string ingredientId,
[FromBody] AdjustStockRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
try
{
var updated = await _inventory.AdjustAsync(cafeId, ingredientId, request, tenant.UserId, ct);
if (updated is null) return NotFoundError();
return Ok(new ApiResponse<object>(true, updated));
}
catch (InvalidOperationException ex) when (ex.Message is "TOTAL_PAID_REQUIRED" or "BRANCH_ID_REQUIRED")
{
return BadRequest(new ApiResponse<object>(false, null,
new ApiError(ex.Message, ex.Message switch
{
"TOTAL_PAID_REQUIRED" => "Enter total paid for stock received.",
_ => "Branch is required for purchase cost."
})));
}
}
[HttpGet("purchases")]
public async Task<IActionResult> PurchasesSummary(
string cafeId,
[FromQuery] string branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (string.IsNullOrWhiteSpace(branchId))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("BRANCH_ID_REQUIRED", "branchId is required.")));
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var summary = await _inventory.GetPurchasesSummaryAsync(
cafeId,
branchId,
from ?? today.AddDays(-30),
to ?? today,
ct);
return Ok(new ApiResponse<object>(true, summary));
}
[HttpGet("menu-items/{menuItemId}/recipe")]
public async Task<IActionResult> GetRecipe(
string cafeId,
string menuItemId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var recipe = await _inventory.GetRecipeAsync(cafeId, menuItemId, ct);
if (recipe is null) return NotFoundError("Menu item not found.");
return Ok(new ApiResponse<object>(true, recipe));
}
[HttpPut("menu-items/{menuItemId}/recipe")]
public async Task<IActionResult> SetRecipe(
string cafeId,
string menuItemId,
[FromBody] SetMenuItemRecipeRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var recipe = await _inventory.SetRecipeAsync(cafeId, menuItemId, request, ct);
if (recipe is null) return NotFoundError("Menu item not found.");
return Ok(new ApiResponse<object>(true, recipe));
}
}
@@ -0,0 +1,78 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Kitchen;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/kitchen-stations")]
public class KitchenStationsController : CafeApiControllerBase
{
private readonly IKitchenStationService _stations;
private readonly IValidator<CreateKitchenStationRequest> _createValidator;
private readonly IValidator<UpdateKitchenStationRequest> _updateValidator;
public KitchenStationsController(
IKitchenStationService stations,
IValidator<CreateKitchenStationRequest> createValidator,
IValidator<UpdateKitchenStationRequest> updateValidator)
{
_stations = stations;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
[HttpGet]
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _stations.ListAsync(cafeId, ct);
return Ok(new ApiResponse<IReadOnlyList<KitchenStationDto>>(true, data));
}
[HttpPost]
public async Task<IActionResult> Create(
string cafeId,
[FromBody] CreateKitchenStationRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _stations.CreateAsync(cafeId, request, ct);
if (data is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID_BRANCH", "Branch not found.")));
return Ok(new ApiResponse<KitchenStationDto>(true, data));
}
[HttpPatch("{id}")]
public async Task<IActionResult> Update(
string cafeId,
string id,
[FromBody] UpdateKitchenStationRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _updateValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _stations.UpdateAsync(cafeId, id, request, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<KitchenStationDto>(true, data));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var ok = await _stations.DeleteAsync(cafeId, id, ct);
if (!ok) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
}
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/media")]
public class MediaController : CafeApiControllerBase
{
private readonly IMediaStorageService _media;
public MediaController(IMediaStorageService media) => _media = media;
[HttpPost("menu-image")]
[RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadMenuImage(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveMenuImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
[HttpPost("menu-video")]
[RequestSizeLimit(25 * 1024 * 1024)]
public Task<IActionResult> UploadMenuVideo(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveMenuVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
[HttpPost("menu-model3d")]
[RequestSizeLimit(8 * 1024 * 1024)]
public async Task<IActionResult> UploadMenuModel3d(
string cafeId,
IFormFile file,
ITenantContext tenant,
IPlatformCatalogService catalog,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var planTier = tenant.PlanTier ?? PlanTier.Free;
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "menu_3d", cancellationToken))
{
return StatusCode(
StatusCodes.Status403Forbidden,
new ApiResponse<object>(
false,
null,
new ApiError("PLAN_FEATURE_DISABLED", "3D menu is not included in your plan. Upgrade to enable it.")));
}
return await Upload(
cafeId,
file,
tenant,
_media.SaveMenuModel3dAsync,
"INVALID_FILE",
"Use GLB (.glb) up to 8MB.",
cancellationToken);
}
[HttpPost("table-image")]
[RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadTableImage(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveTableImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
[HttpPost("table-video")]
[RequestSizeLimit(25 * 1024 * 1024)]
public Task<IActionResult> UploadTableVideo(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveTableVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
[HttpPost("cafe-logo")]
[RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadCafeLogo(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveCafeLogoAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
[HttpPost("cafe-cover")]
[RequestSizeLimit(5 * 1024 * 1024)]
public Task<IActionResult> UploadCafeCover(
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
private async Task<IActionResult> Upload(
string cafeId,
IFormFile file,
ITenantContext tenant,
Func<string, IFormFile, CancellationToken, Task<string?>> save,
string errorCode,
string errorMessage,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (file is null || file.Length == 0)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID_FILE", "No file uploaded.")));
var url = await save(cafeId, file, cancellationToken);
if (url is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError(errorCode, errorMessage)));
return Ok(new ApiResponse<UploadResultDto>(true, new UploadResultDto(url)));
}
}
public record UploadResultDto(string Url);
+169
View File
@@ -0,0 +1,169 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Menu;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/menu")]
public class MenuController : CafeApiControllerBase
{
private readonly IMenuService _menuService;
private readonly IMenuAi3dGenerationService _menuAi3d;
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
public MenuController(
IMenuService menuService,
IMenuAi3dGenerationService menuAi3d,
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
IValidator<CreateMenuItemRequest> createItemValidator)
{
_menuService = menuService;
_menuAi3d = menuAi3d;
_createCategoryValidator = createCategoryValidator;
_createItemValidator = createItemValidator;
}
[HttpGet("categories")]
public async Task<IActionResult> GetCategories(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _menuService.GetCategoriesAsync(cafeId, cancellationToken);
return Ok(new ApiResponse<IReadOnlyList<MenuCategoryDto>>(true, data));
}
[HttpPost("categories")]
public async Task<IActionResult> CreateCategory(
string cafeId,
[FromBody] CreateMenuCategoryRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken);
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
}
[HttpPatch("categories/{id}")]
public async Task<IActionResult> UpdateCategory(
string cafeId,
string id,
[FromBody] UpdateMenuCategoryRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _menuService.UpdateCategoryAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
}
[HttpDelete("categories/{id}")]
public async Task<IActionResult> DeleteCategory(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _menuService.DeleteCategoryAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
[HttpGet("items")]
public async Task<IActionResult> GetItems(
string cafeId,
[FromQuery] string? categoryId,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _menuService.GetItemsAsync(cafeId, categoryId, cancellationToken);
return Ok(new ApiResponse<IReadOnlyList<MenuItemDto>>(true, data));
}
[HttpPost("items")]
public async Task<IActionResult> CreateItem(
string cafeId,
[FromBody] CreateMenuItemRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _menuService.CreateItemAsync(cafeId, request, cancellationToken);
if (data is null) return NotFoundError("Category not found.");
return Ok(new ApiResponse<MenuItemDto>(true, data));
}
[HttpPatch("items/{id}")]
public async Task<IActionResult> UpdateItem(
string cafeId,
string id,
[FromBody] UpdateMenuItemRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _menuService.UpdateItemAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<MenuItemDto>(true, data));
}
[HttpPatch("items/{id}/availability")]
public async Task<IActionResult> SetAvailability(
string cafeId,
string id,
[FromBody] UpdateMenuItemAvailabilityRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _menuService.SetAvailabilityAsync(cafeId, id, request.IsAvailable, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<MenuItemDto>(true, data));
}
[HttpGet("ai-3d/usage")]
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var tier = tenant.PlanTier ?? PlanTier.Free;
var data = await _menuAi3d.GetUsageAsync(cafeId, tier, cancellationToken);
return Ok(new ApiResponse<MenuAi3dUsageDto>(true, data));
}
[HttpPost("items/{id}/ai-3d")]
public async Task<IActionResult> GenerateAi3d(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var tier = tenant.PlanTier ?? PlanTier.Free;
var (data, code, message) = await _menuAi3d.GenerateFromItemImageAsync(cafeId, id, tier, cancellationToken);
if (code is not null)
{
return code switch
{
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? ""))),
"PLAN_FEATURE_DISABLED" => StatusCode(
StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError(code, message ?? ""))),
"PLAN_LIMIT_REACHED" => StatusCode(
StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError(code, message ?? ""))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message ?? "")))
};
}
return Ok(new ApiResponse<MenuAi3dGenerateResultDto>(true, data));
}
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Notifications;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/notifications")]
public class NotificationsController : CafeApiControllerBase
{
private readonly INotificationInboxService _inbox;
public NotificationsController(INotificationInboxService inbox)
{
_inbox = inbox;
}
[HttpGet]
public async Task<IActionResult> List(
string cafeId,
ITenantContext tenant,
[FromQuery] bool unreadOnly = false,
[FromQuery] int limit = 40,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _inbox.ListAsync(cafeId, unreadOnly, limit, ct);
return Ok(new ApiResponse<NotificationListDto>(true, data));
}
[HttpGet("unread-count")]
public async Task<IActionResult> UnreadCount(string cafeId, ITenantContext tenant, CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var count = await _inbox.GetUnreadCountAsync(cafeId, ct);
return Ok(new ApiResponse<object>(true, new { count }));
}
[HttpPost("read")]
public async Task<IActionResult> MarkRead(
string cafeId,
[FromBody] MarkNotificationsReadRequest request,
ITenantContext tenant,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
await _inbox.MarkReadAsync(cafeId, request, ct);
return Ok(new ApiResponse<object>(true, new { read = true }));
}
}
@@ -0,0 +1,235 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Orders;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/orders")]
public class OrdersController : CafeApiControllerBase
{
private readonly IOrderService _orderService;
private readonly IValidator<CreateOrderRequest> _createValidator;
private readonly IValidator<UpdateOrderStatusRequest> _statusValidator;
private readonly IValidator<RecordPaymentsRequest> _paymentsValidator;
private readonly IValidator<AppendOrderItemsRequest> _appendValidator;
private readonly IValidator<UpdateOrderSessionRequest> _sessionValidator;
public OrdersController(
IOrderService orderService,
IValidator<CreateOrderRequest> createValidator,
IValidator<UpdateOrderStatusRequest> statusValidator,
IValidator<RecordPaymentsRequest> paymentsValidator,
IValidator<AppendOrderItemsRequest> appendValidator,
IValidator<UpdateOrderSessionRequest> sessionValidator)
{
_orderService = orderService;
_createValidator = createValidator;
_statusValidator = statusValidator;
_paymentsValidator = paymentsValidator;
_appendValidator = appendValidator;
_sessionValidator = sessionValidator;
}
[HttpGet]
public async Task<IActionResult> GetOrders(
string cafeId,
[FromQuery] OrderStatus? status,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _orderService.GetOrdersAsync(cafeId, status, cancellationToken);
return Ok(new ApiResponse<IReadOnlyList<OrderDto>>(true, data));
}
[HttpGet("open")]
public async Task<IActionResult> GetOpenOrders(
string cafeId,
[FromQuery] string? search,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _orderService.GetOpenOrdersAsync(cafeId, search, cancellationToken);
return Ok(new ApiResponse<IReadOnlyList<OrderDto>>(true, data));
}
[HttpGet("live")]
public async Task<IActionResult> GetLiveOrders(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _orderService.GetLiveOrdersAsync(cafeId, cancellationToken);
return Ok(new ApiResponse<IReadOnlyList<LiveOrderDto>>(true, data));
}
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _orderService.GetOrderAsync(cafeId, id, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<OrderDto>(true, data));
}
[HttpPost]
public async Task<IActionResult> CreateOrder(
string cafeId,
[FromBody] CreateOrderRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _orderService.CreateOrderAsync(cafeId, tenant, request, cancellationToken);
if (!result.Success)
return OrderError(result.ErrorCode!, result.Field);
return Ok(new ApiResponse<OrderDto>(true, result.Data));
}
[HttpPost("{id}/items")]
public async Task<IActionResult> AppendItems(
string cafeId,
string id,
[FromBody] AppendOrderItemsRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _appendValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _orderService.AppendOrderItemsAsync(cafeId, id, request, cancellationToken);
if (!result.Success)
return OrderError(result.ErrorCode!, result.Field);
return Ok(new ApiResponse<OrderDto>(true, result.Data));
}
[HttpPatch("{id}/items/{itemId}/void")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> VoidOrderItem(
string cafeId,
string id,
string itemId,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User context required.")));
var result = await _orderService.VoidOrderItemAsync(cafeId, id, itemId, tenant.UserId, cancellationToken);
if (!result.Success)
return OrderError(result.ErrorCode!, result.Field);
return Ok(new ApiResponse<OrderDto>(true, result.Data));
}
[HttpPost("{id}/transfer")]
[Authorize(Roles = "Manager,Owner,Waiter")]
public async Task<IActionResult> TransferTable(
string cafeId,
string id,
[FromBody] TransferTableRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var result = await _orderService.TransferTableAsync(cafeId, id, request.TargetTableId, cancellationToken);
if (!result.Success)
return OrderError(result.ErrorCode!, result.Field);
return Ok(new ApiResponse<OrderDto>(true, result.Data));
}
[HttpPatch("{id}/session")]
public async Task<IActionResult> UpdateSession(
string cafeId,
string id,
[FromBody] UpdateOrderSessionRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _sessionValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _orderService.UpdateOrderSessionAsync(cafeId, id, request, cancellationToken);
if (!result.Success)
return OrderError(result.ErrorCode!, result.Field);
return Ok(new ApiResponse<OrderDto>(true, result.Data));
}
[HttpPatch("{id}/status")]
public async Task<IActionResult> UpdateStatus(
string cafeId,
string id,
[FromBody] UpdateOrderStatusRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _statusValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _orderService.UpdateStatusAsync(cafeId, id, request.Status, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<OrderDto>(true, data));
}
[HttpPost("{id}/payments")]
public async Task<IActionResult> RecordPayments(
string cafeId,
string id,
[FromBody] RecordPaymentsRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _paymentsValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _orderService.RecordPaymentsAsync(
cafeId, id, request, tenant.UserId, cancellationToken);
if (!result.Success) return OrderError(result.ErrorCode!, result.Field);
return Ok(new ApiResponse<IReadOnlyList<PaymentDto>>(true, result.Data));
}
private IActionResult OrderError(string code, string? field = null) =>
code switch
{
"TABLE_NOT_AVAILABLE" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Table is not available for new orders.", field))),
"TABLE_OCCUPIED" => Conflict(new ApiResponse<object>(
false, null, new ApiError(code, "Table already has an active order.", field))),
"ORDER_NOT_OPEN" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Order is not open for changes.", field))),
"ORDER_NOT_FOUND" => NotFound(new ApiResponse<object>(
false, null, new ApiError(code, "Order not found.", field))),
"ORDER_ALREADY_CLOSED" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Order is already closed.", field))),
"ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>(
false, null, new ApiError(code, "Line item not found.", field))),
"ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Line item is already voided.", field))),
"TABLE_NOT_FOUND" => NotFound(new ApiResponse<object>(
false, null, new ApiError(code, "Table not found.", field))),
"TABLE_CLEANING" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Table is being cleaned.", field))),
"NO_OPEN_SHIFT" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Open the cash register shift before taking payment.", field))),
_ => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Invalid order request.", field)))
};
}
@@ -0,0 +1,53 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Printing;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/branches/{branchId}/pos-device")]
[Authorize(Roles = "Cashier,Manager,Owner")]
public class PosDeviceController : CafeApiControllerBase
{
private readonly IPosDeviceService _posDevice;
private readonly IValidator<PosPaymentRequest> _validator;
public PosDeviceController(IPosDeviceService posDevice, IValidator<PosPaymentRequest> validator)
{
_posDevice = posDevice;
_validator = validator;
}
[HttpPost("payment-request")]
public async Task<IActionResult> RequestPayment(
string cafeId,
string branchId,
[FromBody] PosPaymentRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _posDevice.SendPaymentRequestAsync(cafeId, branchId, request, ct);
if (!result.Success)
{
return BadRequest(new ApiResponse<object>(
false,
null,
new ApiError(
result.ErrorCode ?? "POS_DEVICE_FAILED",
result.Detail ?? "Could not send amount to POS device.")));
}
return Ok(new ApiResponse<PosPaymentResultDto>(
true,
new PosPaymentResultDto(!result.Skipped, result.Skipped, null)));
}
}
@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Printing;
using Meezi.API.Services.Printing;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/print")]
public class PrintController : CafeApiControllerBase
{
private readonly IPrinterService _printer;
public PrintController(IPrinterService printer) => _printer = printer;
[HttpPost("receipt/{orderId}")]
public async Task<IActionResult> PrintReceipt(
string cafeId,
string orderId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var result = await _printer.PrintReceiptAsync(cafeId, orderId, ct);
return ToActionResult(result);
}
[HttpPost("kitchen/{orderId}")]
public async Task<IActionResult> PrintKitchen(
string cafeId,
string orderId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var result = await _printer.PrintKitchenTicketAsync(cafeId, orderId, ct);
return ToActionResult(result);
}
[HttpPost("test")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> TestPrint(
string cafeId,
[FromBody] TestPrintRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var result = await _printer.TestPrintAsync(request.PrinterIp, request.Port, ct);
return ToActionResult(result);
}
private IActionResult ToActionResult(PrintResult result)
{
if (result.Success)
return Ok(new ApiResponse<PrintJobResultDto>(true,
new PrintJobResultDto(true, null, null)));
var status = result.ErrorCode switch
{
"PRINTER_NOT_CONFIGURED" or "KITCHEN_PRINTER_NOT_CONFIGURED" => StatusCodes.Status400BadRequest,
"ORDER_NOT_FOUND" => StatusCodes.Status404NotFound,
_ => StatusCodes.Status502BadGateway
};
return StatusCode(status, new ApiResponse<PrintJobResultDto>(false, null,
new ApiError(result.ErrorCode!, MessageForCode(result.ErrorCode), null)));
}
private static string MessageForCode(string? code) => code switch
{
"PRINTER_NOT_CONFIGURED" => "Receipt printer IP is not configured for this branch.",
"KITCHEN_PRINTER_NOT_CONFIGURED" => "Kitchen printer IP is not configured for this branch.",
"PRINTER_CONNECTION_FAILED" => "Could not connect to the printer.",
"ORDER_NOT_FOUND" => "Order not found.",
_ => "Print failed."
};
}
@@ -0,0 +1,53 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[ApiController]
[Route("api/public/coffee-advisor")]
public class PublicCoffeeAdvisorController : ControllerBase
{
private readonly ICoffeeAdvisorService _advisor;
private readonly IValidator<CoffeeAdvisorRequest> _validator;
public PublicCoffeeAdvisorController(
ICoffeeAdvisorService advisor,
IValidator<CoffeeAdvisorRequest> validator)
{
_advisor = advisor;
_validator = validator;
}
[HttpPost]
[EnableRateLimiting("public-write")]
public async Task<IActionResult> Recommend(
[FromBody] CoffeeAdvisorRequest request,
CancellationToken cancellationToken)
{
var validation = await _validator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid)
{
var first = validation.Errors[0];
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var (data, code, message) = await _advisor.RecommendAsync(request, cancellationToken);
if (data is null)
{
return code switch
{
"AI_NOT_CONFIGURED" => StatusCode(503, new ApiResponse<object>(false, null,
new ApiError(code, message ?? "Advisor unavailable."))),
_ => BadRequest(new ApiResponse<object>(false, null,
new ApiError(code ?? "AI_FAILED", message ?? "Request failed.")))
};
}
return Ok(new ApiResponse<CoffeeAdvisorResultDto>(true, data));
}
}
@@ -0,0 +1,370 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Meezi.API.Models.Public;
using Meezi.API.Models.Queue;
using Meezi.API.Security;
using Meezi.API.Services;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/public")]
public class PublicController : ControllerBase
{
private readonly IPublicService _public;
private readonly IReviewService _reviews;
private readonly IValidator<GuestCreateOrderRequest> _orderValidator;
private readonly IValidator<PlaceGuestOrderRequest> _qrOrderValidator;
private readonly IValidator<CreateReservationRequest> _reservationValidator;
private readonly IValidator<CreateCafeReviewRequest> _reviewValidator;
private readonly IAbuseProtectionService _abuse;
private readonly AbuseProtectionOptions _securityOptions;
public PublicController(
IPublicService publicService,
IReviewService reviews,
IValidator<GuestCreateOrderRequest> orderValidator,
IValidator<PlaceGuestOrderRequest> qrOrderValidator,
IValidator<CreateReservationRequest> reservationValidator,
IValidator<CreateCafeReviewRequest> reviewValidator,
IAbuseProtectionService abuse,
IOptions<AbuseProtectionOptions> securityOptions)
{
_public = publicService;
_reviews = reviews;
_orderValidator = orderValidator;
_qrOrderValidator = qrOrderValidator;
_reservationValidator = reservationValidator;
_reviewValidator = reviewValidator;
_abuse = abuse;
_securityOptions = securityOptions.Value;
}
[HttpGet("security-config")]
[EnableRateLimiting("public-read")]
public IActionResult GetSecurityConfig()
{
var dto = new PublicSecurityConfigDto(
_securityOptions.Enabled,
_abuse.CaptchaSiteKey,
_securityOptions.RequireCaptchaOnPublicWrites && _abuse.IsCaptchaConfigured);
return Ok(new ApiResponse<PublicSecurityConfigDto>(true, dto));
}
[HttpGet("discover")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> Discover(
[FromQuery] string? city,
[FromQuery] string? q,
[FromQuery] double? minRating,
[FromQuery] string? sort,
[FromQuery] string? themes,
[FromQuery] string? vibes,
[FromQuery] string? occasions,
[FromQuery] string? spaceFeatures,
[FromQuery] string? noise,
[FromQuery] string? priceTier,
[FromQuery] string? size,
[FromQuery] bool requireProfile = true,
[FromQuery] bool openNow = false,
CancellationToken ct = default)
{
var filters = DiscoverFilterParams.FromQuery(
city, q, minRating, sort, themes, vibes, occasions, spaceFeatures,
noise, priceTier, size, requireProfile, openNow);
var data = await _public.DiscoverAsync(filters, ct);
return Ok(new ApiResponse<IReadOnlyList<CafeDiscoverDto>>(true, data));
}
[HttpGet("cafes/{slug}/reviews")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetReviews(
string slug,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
var cafe = await _public.GetCafeAsync(slug, ct);
if (cafe is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
var data = await _reviews.GetReviewsAsync(cafe.Id, page, pageSize, publicOnly: true, ct);
return Ok(new ApiResponse<IReadOnlyList<CafeReviewDto>>(true, data));
}
[HttpPost("cafes/{slug}/reviews")]
public async Task<IActionResult> CreateReview(
string slug,
[FromBody] CreateCafeReviewRequest request,
CancellationToken ct)
{
var validation = await _reviewValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var cafe = await _public.GetCafeAsync(slug, ct);
if (cafe is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
var (data, code, message) = await _reviews.CreateReviewAsync(cafe.Id, request, ct);
if (data is null)
{
return StatusCode(
PublicWriteStatusCodes.ToHttpStatus(code),
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Could not submit review.")));
}
return Ok(new ApiResponse<CafeReviewDto>(true, data));
}
[HttpPost("cafes/{slug}/reviews/upload")]
[RequestSizeLimit(20 * 1024 * 1024)]
public async Task<IActionResult> CreateReviewWithPhotos(
string slug,
[FromForm] string authorName,
[FromForm] int rating,
[FromForm] string? comment,
[FromForm] string? authorPhone,
[FromForm] string? captchaToken,
[FromForm] List<IFormFile>? photos,
CancellationToken ct)
{
var request = new CreateCafeReviewRequest(
authorName?.Trim() ?? "",
authorPhone?.Trim(),
rating,
comment?.Trim(),
captchaToken);
var validation = await _reviewValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var cafe = await _public.GetCafeAsync(slug, ct);
if (cafe is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
var (data, code, message) = await _reviews.CreateReviewWithPhotosAsync(
cafe.Id,
request,
photos ?? [],
ct);
if (data is null)
{
return StatusCode(
PublicWriteStatusCodes.ToHttpStatus(code),
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Could not submit review.")));
}
return Ok(new ApiResponse<CafeReviewDto>(true, data));
}
[HttpGet("badge-catalog")]
[EnableRateLimiting("public-read")]
public IActionResult BadgeCatalog()
{
var data = Core.Discover.CafeBadgeCatalog.All
.Select(b => new CafeBadgePublicDto(b.Key, b.LabelFa, b.Icon))
.ToList();
return Ok(new ApiResponse<IReadOnlyList<CafeBadgePublicDto>>(true, data));
}
[HttpGet("cafes/{slug}")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetCafe(string slug, CancellationToken ct)
{
var data = await _public.GetCafeAsync(slug, ct);
if (data is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
return Ok(new ApiResponse<CafePublicDto>(true, data));
}
[HttpGet("cafes/{slug}/menu")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetMenu(string slug, CancellationToken ct)
{
var data = await _public.GetMenuAsync(slug, ct);
if (data is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
return Ok(new ApiResponse<PublicMenuDto>(true, data));
}
[HttpPost("cafes/{slug}/orders")]
public async Task<IActionResult> PlaceOrder(
string slug,
[FromBody] GuestCreateOrderRequest request,
CancellationToken ct)
{
var validation = await _orderValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var (data, code, message) = await _public.PlaceOrderAsync(slug, request, ct);
if (data is null)
{
return StatusCode(
PublicWriteStatusCodes.ToHttpStatus(code),
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
}
return Ok(new ApiResponse<GuestOrderPlacedDto>(true, data));
}
[HttpGet("orders/{orderId}/track")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> TrackOrder(
string orderId,
[FromQuery] string token,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Tracking token is required.")));
var data = await _public.TrackOrderAsync(orderId, token, ct);
if (data is null) return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Order not found.")));
return Ok(new ApiResponse<OrderTrackDto>(true, data));
}
[HttpGet("{cafeId}/branches/{branchId}/menu")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetBranchMenu(
string cafeId,
string branchId,
CancellationToken ct)
{
var data = await _public.GetBranchMenuAsync(cafeId, branchId, ct);
if (data is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Branch or menu not found.")));
return Ok(new ApiResponse<PublicMenuDto>(true, data));
}
[HttpGet("{cafeId}/branches/{branchId}/identity")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetBranchIdentity(
string cafeId,
string branchId,
[FromServices] IBranchIdentityService identity,
CancellationToken ct)
{
var data = await identity.GetEffectiveIdentityAsync(cafeId, branchId, ct);
if (data is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Branch not found.")));
return Ok(new ApiResponse<BranchEffectiveIdentityDto>(true, data));
}
[HttpPost("{cafeId}/branches/{branchId}/orders")]
public async Task<IActionResult> PlaceBranchGuestOrder(
string cafeId,
string branchId,
[FromBody] PlaceGuestOrderRequest request,
CancellationToken ct)
{
var validation = await _qrOrderValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var (data, code, message) = await _public.PlaceBranchGuestOrderAsync(cafeId, branchId, request, ct);
if (data is null)
{
return StatusCode(
PublicWriteStatusCodes.ToHttpStatus(code),
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
}
return Ok(new ApiResponse<GuestQrOrderPlacedDto>(true, data));
}
[HttpPost("cafes/{slug}/reservations")]
public async Task<IActionResult> CreateReservation(
string slug,
[FromBody] CreateReservationRequest request,
CancellationToken ct)
{
var validation = await _reservationValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
var first = validation.Errors.First();
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)));
}
var (data, code, message) = await _public.CreateReservationAsync(slug, request, ct);
if (data is null)
{
return StatusCode(
PublicWriteStatusCodes.ToHttpStatus(code),
new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
}
return Ok(new ApiResponse<ReservationDto>(true, data));
}
[HttpPost("cafes/{slug}/queue/tickets")]
[EnableRateLimiting("public-write")]
public async Task<IActionResult> IssuePublicQueueTicket(
string slug,
[FromBody] IssueQueueTicketRequest request,
[FromServices] IQueueService queue,
[FromServices] AppDbContext db,
CancellationToken ct)
{
var cafe = await db.Cafes.AsNoTracking()
.FirstOrDefaultAsync(c => c.Slug == slug && c.DeletedAt == null, ct);
if (cafe is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
var (ticket, code, message) = await queue.IssuePublicAsync(cafe.Id, cafe.PlanTier, request, ct);
if (ticket is null)
{
var status = code == "PLAN_LIMIT_REACHED" ? 403 : 400;
return StatusCode(status, new ApiResponse<object>(false, null,
new ApiError(code ?? "ERROR", message ?? "Could not issue ticket.")));
}
return Ok(new ApiResponse<QueueTicketDto>(true, ticket));
}
[HttpPost("{cafeId}/tables/{tableId}/call-waiter")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> CallWaiter(
string cafeId,
string tableId,
[FromServices] AppDbContext db,
[FromServices] IOrderNotificationService notifications,
[FromServices] IMemoryCache cache,
CancellationToken ct)
{
var cooldownKey = $"call-waiter:{cafeId}:{tableId}";
if (cache.TryGetValue(cooldownKey, out _))
return StatusCode(StatusCodes.Status429TooManyRequests,
new ApiResponse<object>(false, null, new ApiError("RATE_LIMITED", "Please wait 60 seconds before calling again.")));
var table = await db.Tables.AsNoTracking()
.Where(t => t.Id == tableId && t.CafeId == cafeId && t.DeletedAt == null && t.IsActive)
.Select(t => new { t.Id, t.Number })
.FirstOrDefaultAsync(ct);
if (table is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Table not found.")));
cache.Set(cooldownKey, true, TimeSpan.FromSeconds(60));
await notifications.NotifyCallWaiterAsync(cafeId, tableId, table.Number, ct);
return Ok(new ApiResponse<object>(true, null));
}
}
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Meezi.API.Models.Discover;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/public")]
public class PublicDiscoverController : ControllerBase
{
[HttpGet("discover-profile/taxonomy")]
[EnableRateLimiting("public-read")]
public IActionResult Taxonomy() =>
Ok(new ApiResponse<DiscoverProfileTaxonomyDto>(true, CafeDiscoverProfileMapping.Taxonomy()));
/// <summary>
/// Parse a free-text Persian query and return the filter hints the NLP engine detected.
/// Used by the frontend to show "detected filters" chips under the AI search box.
/// </summary>
[HttpGet("discover/nlp-parse")]
[EnableRateLimiting("public-read")]
public IActionResult NlpParse([FromQuery] string q)
{
if (string.IsNullOrWhiteSpace(q))
return Ok(new ApiResponse<DiscoverNlpHintsDto>(true, DiscoverNlpHintsDto.Empty));
var hints = DiscoverNlpParser.Parse(q);
var dto = new DiscoverNlpHintsDto(
hints.Themes,
hints.Vibes,
hints.Occasions,
hints.SpaceFeatures,
hints.NoiseLevel,
hints.PriceTier,
hints.Size);
return Ok(new ApiResponse<DiscoverNlpHintsDto>(true, dto));
}
}
+32
View File
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Meezi.API.Models.Tables;
using Meezi.API.Services;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/q")]
public class QrController : ControllerBase
{
private readonly ITableService _tableService;
public QrController(ITableService tableService)
{
_tableService = tableService;
}
[HttpGet("{qrCode}")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> Resolve(string qrCode, CancellationToken cancellationToken)
{
var data = await _tableService.ResolveQrAsync(qrCode, cancellationToken);
if (data is null)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "QR code not found.")));
return Ok(new ApiResponse<QrResolveResponse>(true, data));
}
}
@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Queue;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/queue")]
public class QueueController : CafeApiControllerBase
{
private readonly IQueueService _queue;
public QueueController(IQueueService queue)
{
_queue = queue;
}
[HttpGet("today")]
public async Task<IActionResult> GetToday(
string cafeId,
ITenantContext tenant,
[FromQuery] string? branchId = null,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var board = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
return Ok(new ApiResponse<QueueBoardDto>(true, board));
}
[HttpPost("next")]
public async Task<IActionResult> IssueNext(
string cafeId,
[FromBody] IssueQueueTicketRequest request,
ITenantContext tenant,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var (ticket, error) = await _queue.IssueNextAsync(cafeId, tenant.UserId, request, ct);
if (error == "BRANCH_NOT_FOUND")
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Branch not found.")));
if (error == "ORDER_NOT_FOUND")
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Order not found.")));
return Ok(new ApiResponse<QueueTicketDto>(true, ticket));
}
[HttpPatch("{ticketId}/status")]
public async Task<IActionResult> UpdateStatus(
string cafeId,
string ticketId,
[FromBody] UpdateQueueTicketStatusRequest request,
ITenantContext tenant,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var (ticket, error) = await _queue.UpdateStatusAsync(cafeId, ticketId, request.Status, ct);
if (error == "NOT_FOUND")
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Ticket not found.")));
if (error == "TICKET_EXPIRED")
return BadRequest(new ApiResponse<object>(false, null,
new ApiError(error, "Ticket is from a previous day.")));
return Ok(new ApiResponse<QueueTicketDto>(true, ticket));
}
[HttpPost("call-next")]
public async Task<IActionResult> CallNext(
string cafeId,
ITenantContext tenant,
[FromQuery] string? branchId = null,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var board = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
var next = board.Tickets.FirstOrDefault(t => t.Status == QueueTicketStatus.Waiting);
if (next is null)
return Ok(new ApiResponse<QueueBoardDto>(true, board));
foreach (var called in board.Tickets.Where(t => t.Status == QueueTicketStatus.Called))
{
await _queue.UpdateStatusAsync(cafeId, called.Id, QueueTicketStatus.Done, ct);
}
await _queue.UpdateStatusAsync(cafeId, next.Id, QueueTicketStatus.Called, ct);
var updated = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
return Ok(new ApiResponse<QueueBoardDto>(true, updated));
}
}
@@ -0,0 +1,193 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Reports;
using Meezi.API.Services;
using Meezi.API.Utils;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/reports")]
public class ReportsController : CafeApiControllerBase
{
private readonly IReportService _reports;
private readonly IDailyReportService _dailyReports;
public ReportsController(IReportService reports, IDailyReportService dailyReports)
{
_reports = reports;
_dailyReports = dailyReports;
}
[HttpGet("daily")]
public async Task<IActionResult> GetDailySnapshot(
string cafeId,
[FromQuery] string branchId,
[FromQuery] string? date,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (string.IsNullOrWhiteSpace(branchId))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
if (!TryParseReportDate(date, out var reportDate))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid date. Use yyyy-MM-dd.", "date")));
if (EnsureReportDateAllowed(tenant, reportDate) is { } planError) return planError;
var snapshot = await _dailyReports.GetReportAsync(cafeId, branchId, reportDate, ct);
if (snapshot is null)
snapshot = await _dailyReports.GenerateReportAsync(cafeId, branchId, reportDate, ct);
return Ok(new ApiResponse<DailyReportSnapshotDto>(true, snapshot));
}
[HttpGet("daily/range")]
public async Task<IActionResult> GetDailyRange(
string cafeId,
[FromQuery] string? branchId,
[FromQuery] string from,
[FromQuery] string to,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!TryParseReportDate(from, out var startDate) || !TryParseReportDate(to, out var endDate))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
var today = IranCalendar.TodayInIran;
var tier = tenant.PlanTier ?? PlanTier.Free;
if (!ReportPlanGate.IsDateInRange(tier, startDate, today)
|| !ReportPlanGate.IsDateInRange(tier, endDate, today))
{
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
}
var clamped = ReportPlanGate.ClampRange(tier, startDate, endDate, today);
if (clamped is null)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid date range.", "from")));
var data = await _dailyReports.GetReportRangeAsync(
cafeId, branchId, clamped.Value.From, clamped.Value.To, ct);
return Ok(new ApiResponse<IReadOnlyList<DailyReportSnapshotDto>>(true, data));
}
[HttpGet("summary")]
public async Task<IActionResult> GetSummary(
string cafeId,
ITenantContext tenant,
[FromQuery] int days = 30,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var tier = tenant.PlanTier ?? PlanTier.Free;
var maxDays = Core.Constants.PlanLimits.MaxReportHistoryDays(tier);
if (days > maxDays && maxDays != int.MaxValue)
{
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "days")));
}
days = Math.Min(days, maxDays == int.MaxValue ? 365 : maxDays);
var data = await _dailyReports.GetSummaryAsync(cafeId, days, ct);
return Ok(new ApiResponse<DailyReportSummaryDto>(true, data));
}
[HttpGet("daily/live")]
public async Task<IActionResult> GetDailyLive(
string cafeId,
[FromQuery] string? date,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (date is not null && !JalaliCalendarHelper.TryParseJalaliDate(date, out _, out _, out _))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid Jalali date. Use yyyy-MM-dd.")));
var data = await _reports.GetDailyReportAsync(cafeId, date ?? string.Empty, ct);
return Ok(new ApiResponse<DailyReportDto>(true, data));
}
[HttpGet("monthly")]
public async Task<IActionResult> GetMonthly(
string cafeId,
[FromQuery] string? month,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (month is not null && !JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
var data = await _reports.GetMonthlyReportAsync(cafeId, month ?? string.Empty, ct);
return Ok(new ApiResponse<MonthlyReportDto>(true, data));
}
[HttpGet("trend")]
public async Task<IActionResult> GetTrend(
string cafeId,
ITenantContext tenant,
[FromQuery] int days = 7,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _reports.GetTrendAsync(cafeId, days, ct);
return Ok(new ApiResponse<IReadOnlyList<TrendDayDto>>(true, data));
}
[HttpGet("export")]
public async Task<IActionResult> Export(
string cafeId,
[FromQuery] string month,
ITenantContext tenant,
[FromQuery] string format = "excel",
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (!string.Equals(format, "excel", StringComparison.OrdinalIgnoreCase))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Only excel format is supported.")));
if (!JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
var bytes = await _reports.ExportExcelAsync(cafeId, month, ct);
var fileName = $"meezi-report-{month}.xlsx";
return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
}
private static bool TryParseReportDate(string? value, out DateOnly date)
{
if (string.IsNullOrWhiteSpace(value))
{
date = IranCalendar.TodayInIran;
return true;
}
return DateOnly.TryParse(value, out date);
}
private IActionResult? EnsureReportDateAllowed(ITenantContext tenant, DateOnly date)
{
var tier = tenant.PlanTier ?? PlanTier.Free;
var today = IranCalendar.TodayInIran;
if (ReportPlanGate.IsDateInRange(tier, date, today))
return null;
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
}
}
@@ -0,0 +1,71 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/reservations")]
public class ReservationsController : CafeApiControllerBase
{
private readonly IReservationService _reservations;
private readonly IValidator<CreateReservationRequest> _createValidator;
public ReservationsController(
IReservationService reservations,
IValidator<CreateReservationRequest> createValidator)
{
_reservations = reservations;
_createValidator = createValidator;
}
[HttpPost]
public async Task<IActionResult> Create(
string cafeId,
[FromBody] CreateReservationRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _reservations.CreateAsync(cafeId, request, ct);
if (data is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID_TABLE", "Table not found.")));
return Ok(new ApiResponse<ReservationDto>(true, data));
}
[HttpGet]
public async Task<IActionResult> List(
string cafeId,
ITenantContext tenant,
[FromQuery] DateOnly? date,
[FromQuery] ReservationStatus? status,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _reservations.GetReservationsAsync(cafeId, date, status, ct);
return Ok(new ApiResponse<IReadOnlyList<ReservationDto>>(true, data));
}
[HttpPatch("{id}/status")]
public async Task<IActionResult> UpdateStatus(
string cafeId,
string id,
[FromBody] UpdateReservationStatusRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _reservations.UpdateStatusAsync(cafeId, id, request.Status, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<ReservationDto>(true, data));
}
}
public record UpdateReservationStatusRequest(ReservationStatus Status);
@@ -0,0 +1,115 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Shifts;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/branches/{branchId}/shifts")]
public class ShiftsController : CafeApiControllerBase
{
private readonly IShiftService _shifts;
private readonly IValidator<OpenShiftRequest> _openValidator;
private readonly IValidator<CloseShiftRequest> _closeValidator;
public ShiftsController(
IShiftService shifts,
IValidator<OpenShiftRequest> openValidator,
IValidator<CloseShiftRequest> closeValidator)
{
_shifts = shifts;
_openValidator = openValidator;
_closeValidator = closeValidator;
}
[HttpPost("open")]
public async Task<IActionResult> Open(
string cafeId,
string branchId,
[FromBody] OpenShiftRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status401Unauthorized,
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
var validation = await _openValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _shifts.OpenShiftAsync(cafeId, branchId, request.OpeningCash, tenant.UserId, ct);
return ShiftResult(result);
}
[HttpPost("{id}/close")]
public async Task<IActionResult> Close(
string cafeId,
string branchId,
string id,
[FromBody] CloseShiftRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(StatusCodes.Status401Unauthorized,
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
var validation = await _closeValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var result = await _shifts.CloseShiftAsync(cafeId, id, request.ClosingCash, tenant.UserId, ct);
return ShiftResult(result);
}
[HttpGet("current")]
public async Task<IActionResult> Current(
string cafeId,
string branchId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _shifts.GetCurrentShiftAsync(cafeId, branchId, ct);
if (data is null)
return NotFound(new ApiResponse<object>(false, null,
new ApiError("NO_OPEN_SHIFT", "No open cash register shift for this branch.")));
return Ok(new ApiResponse<ShiftDto>(true, data));
}
[HttpGet("{id}/transactions")]
public async Task<IActionResult> Transactions(
string cafeId,
string branchId,
string id,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _shifts.GetTransactionsAsync(cafeId, id, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<IReadOnlyList<CashTransactionDto>>(true, data));
}
private IActionResult ShiftResult(ShiftServiceResult<ShiftDto> result) =>
result.ErrorCode switch
{
null => Ok(new ApiResponse<ShiftDto>(true, result.Data)),
"SHIFT_ALREADY_OPEN" => Conflict(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode, "This branch already has an open shift.", result.Field))),
"BRANCH_NOT_FOUND" => NotFound(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode, "Branch not found.", result.Field))),
"SHIFT_NOT_FOUND" => NotFound(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode, "Shift not found.", result.Field))),
"SHIFT_ALREADY_CLOSED" => BadRequest(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode, "Shift is already closed.", result.Field))),
_ => BadRequest(new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode ?? "SHIFT_ERROR", "Shift operation failed.", result.Field)))
};
}
@@ -0,0 +1,65 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Crm;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/sms")]
public class SmsController : CafeApiControllerBase
{
private readonly ISmsMarketingService _smsMarketingService;
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
public SmsController(
ISmsMarketingService smsMarketingService,
IValidator<SendSmsCampaignRequest> campaignValidator)
{
_smsMarketingService = smsMarketingService;
_campaignValidator = campaignValidator;
}
[HttpGet("usage")]
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (tenant.PlanTier is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
var data = await _smsMarketingService.GetUsageAsync(cafeId, tenant.PlanTier.Value, cancellationToken);
return Ok(new ApiResponse<SmsUsageDto>(true, data));
}
[HttpPost("campaign")]
public async Task<IActionResult> SendCampaign(
string cafeId,
[FromBody] SendSmsCampaignRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (tenant.PlanTier is null)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var (success, data, code, message) = await _smsMarketingService.SendCampaignAsync(
cafeId, tenant.PlanTier.Value, request, cancellationToken);
if (!success)
{
return code switch
{
"PLAN_LIMIT_REACHED" => StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError(code, message!))),
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!)))
};
}
return Ok(new ApiResponse<SmsCampaignResult>(true, data));
}
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Services.Delivery;
using Meezi.Core.Enums;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/webhooks/snappfood")]
public class SnappfoodWebhookController : ControllerBase
{
private readonly IDeliveryWebhookIngressService _ingress;
public SnappfoodWebhookController(IDeliveryWebhookIngressService ingress) => _ingress = ingress;
[HttpPost]
public async Task<IActionResult> Receive(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Snappfood-Signature"].FirstOrDefault()
?? Request.Headers["X-Hub-Signature-256"].FirstOrDefault();
var result = await _ingress.ReceiveAsync(DeliveryPlatform.Snappfood, rawBody, signature, ct);
if (!result.Accepted)
{
var status = result.ErrorCode == "UNAUTHORIZED"
? StatusCodes.Status401Unauthorized
: StatusCodes.Status400BadRequest;
return StatusCode(status, new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode ?? "ERROR", result.Message ?? "Failed.")));
}
return Ok(new ApiResponse<object>(true, new { received = true, logId = result.WebhookLogId }));
}
}
@@ -0,0 +1,84 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Infrastructure.Models;
using Meezi.Infrastructure.Services;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/support/tickets")]
public class SupportTicketsController : CafeApiControllerBase
{
private readonly ISupportTicketService _tickets;
public SupportTicketsController(ISupportTicketService tickets)
{
_tickets = tickets;
}
[HttpGet]
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var list = await _tickets.ListForCafeAsync(cafeId, cancellationToken);
return Ok(new ApiResponse<object>(true, list));
}
[HttpGet("{ticketId}")]
public async Task<IActionResult> Get(
string cafeId,
string ticketId,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var detail = await _tickets.GetForCafeAsync(cafeId, ticketId, cancellationToken);
if (detail is null)
return NotFoundError("Ticket not found.");
return Ok(new ApiResponse<object>(true, detail));
}
[HttpPost]
public async Task<IActionResult> Create(
string cafeId,
[FromBody] CreateSupportTicketRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(403, new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User required.")));
var detail = await _tickets.CreateForCafeAsync(cafeId, tenant.UserId, request, cancellationToken);
return Ok(new ApiResponse<object>(true, detail));
}
[HttpPost("{ticketId}/messages")]
public async Task<IActionResult> Reply(
string cafeId,
string ticketId,
[FromBody] ReplySupportTicketRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (string.IsNullOrEmpty(tenant.UserId))
return StatusCode(403, new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User required.")));
var existing = await _tickets.GetForCafeAsync(cafeId, ticketId, cancellationToken);
if (existing is null)
return NotFoundError("Ticket not found.");
if (existing.Ticket.Status is Core.Enums.SupportTicketStatus.Closed
or Core.Enums.SupportTicketStatus.Resolved)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("TICKET_CLOSED", "This ticket is closed and cannot receive new messages.")));
var detail = await _tickets.ReplyAsMerchantAsync(cafeId, ticketId, tenant.UserId, request, cancellationToken);
if (detail is null)
return NotFoundError("Ticket not found.");
return Ok(new ApiResponse<object>(true, detail));
}
}
@@ -0,0 +1,158 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Orders;
using Meezi.API.Models.Tables;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/tables")]
public class TablesController : CafeApiControllerBase
{
private readonly ITableService _tableService;
private readonly IOrderService _orderService;
private readonly IValidator<CreateTableRequest> _createValidator;
private readonly IValidator<PatchTableRequest> _patchValidator;
private readonly IValidator<SetTableCleaningRequest> _cleaningValidator;
public TablesController(
ITableService tableService,
IOrderService orderService,
IValidator<CreateTableRequest> createValidator,
IValidator<PatchTableRequest> patchValidator,
IValidator<SetTableCleaningRequest> cleaningValidator)
{
_tableService = tableService;
_orderService = orderService;
_createValidator = createValidator;
_patchValidator = patchValidator;
_cleaningValidator = cleaningValidator;
}
[HttpGet("board")]
public async Task<IActionResult> GetBoard(
string cafeId,
ITenantContext tenant,
[FromQuery] bool activeOnly = true,
[FromQuery] string? branchId = null,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _tableService.GetTableBoardAsync(cafeId, activeOnly, branchId, ct);
return Ok(new ApiResponse<IReadOnlyList<TableBoardDto>>(true, data));
}
[HttpGet]
public async Task<IActionResult> GetTables(
string cafeId,
ITenantContext tenant,
[FromQuery] string? branchId = null,
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _tableService.GetTablesAsync(cafeId, branchId, ct);
return Ok(new ApiResponse<IReadOnlyList<TableDto>>(true, data));
}
[HttpPost]
public async Task<IActionResult> CreateTable(
string cafeId,
[FromBody] CreateTableRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _createValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _tableService.CreateTableAsync(cafeId, request, ct);
if (data is null) return NotFoundError("Branch not found.");
return Ok(new ApiResponse<TableDto>(true, data));
}
[HttpPatch("{id}")]
public async Task<IActionResult> PatchTable(
string cafeId,
string id,
[FromBody] PatchTableRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _patchValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _tableService.PatchTableAsync(cafeId, id, request, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<TableDto>(true, data));
}
[HttpGet("{id}/active-order")]
public async Task<IActionResult> GetActiveOrder(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _orderService.GetActiveOrderByTableAsync(cafeId, id, ct);
if (data is null) return NotFoundError("No active order for this table.");
return Ok(new ApiResponse<OrderDto>(true, data));
}
[HttpDelete("{id}")]
[Authorize(Roles = "Manager,Owner")]
public async Task<IActionResult> DeleteTable(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var result = await _tableService.DeleteTableAsync(cafeId, id, ct);
if (!result.Success)
{
var status = result.ErrorCode == "TABLE_HAS_OPEN_ORDER"
? StatusCodes.Status409Conflict
: StatusCodes.Status400BadRequest;
return StatusCode(status,
new ApiResponse<object>(false, null, new ApiError(result.ErrorCode!, result.Message ?? result.ErrorCode!)));
}
return Ok(new ApiResponse<object>(true, result.Data));
}
[HttpPatch("{id}/cleaning")]
public async Task<IActionResult> SetCleaning(
string cafeId,
string id,
[FromBody] SetTableCleaningRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var validation = await _cleaningValidator.ValidateAsync(request, ct);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var data = await _tableService.SetTableCleaningAsync(cafeId, id, request.IsCleaning, ct);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<TableBoardDto>(true, data));
}
[HttpGet("{id}/qr")]
public async Task<IActionResult> GetQrPng(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var png = await _tableService.GetQrPngAsync(cafeId, id, ct);
if (png is null) return NotFoundError();
return File(png, "image/png", $"table-{id}-qr.png");
}
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Services.Delivery;
using Meezi.Core.Enums;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/webhooks/tap30")]
public class Tap30WebhookController : ControllerBase
{
private readonly IDeliveryWebhookIngressService _ingress;
public Tap30WebhookController(IDeliveryWebhookIngressService ingress) => _ingress = ingress;
[HttpPost]
public async Task<IActionResult> Receive(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Tap30-Signature"].FirstOrDefault()
?? Request.Headers["X-Hub-Signature-256"].FirstOrDefault();
var result = await _ingress.ReceiveAsync(DeliveryPlatform.Tap30, rawBody, signature, ct);
if (!result.Accepted)
{
var status = result.ErrorCode == "UNAUTHORIZED"
? StatusCodes.Status401Unauthorized
: StatusCodes.Status400BadRequest;
return StatusCode(status, new ApiResponse<object>(false, null,
new ApiError(result.ErrorCode ?? "ERROR", result.Message ?? "Failed.")));
}
return Ok(new ApiResponse<object>(true, new { received = true, logId = result.WebhookLogId }));
}
}
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/tax/taraz")]
public class TarazController : CafeApiControllerBase
{
private readonly ITarazTaxService _taraz;
public TarazController(ITarazTaxService taraz) => _taraz = taraz;
[HttpPost("submit")]
public async Task<IActionResult> Submit(
string cafeId,
ITenantContext tenant,
[FromQuery] DateTime? date,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var targetDate = date ?? DateTime.UtcNow.Date;
var result = await _taraz.SubmitDailyInvoicesAsync(cafeId, targetDate, ct);
if (!result.Success)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("TARAZ_ERROR", result.Message ?? "Submit failed.")));
return Ok(new ApiResponse<object>(true, new { trackingCode = result.TrackingCode, message = result.Message }));
}
}
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Taxes;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/taxes")]
public class TaxesController : CafeApiControllerBase
{
private readonly ITaxService _taxService;
public TaxesController(ITaxService taxService) => _taxService = taxService;
[HttpGet]
public async Task<IActionResult> GetAll(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var data = await _taxService.GetAllAsync(cafeId, cancellationToken);
return Ok(new ApiResponse<IReadOnlyList<TaxDto>>(true, data));
}
[HttpPost]
public async Task<IActionResult> Create(
string cafeId,
[FromBody] CreateTaxRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
var data = await _taxService.CreateAsync(cafeId, request, cancellationToken);
return Ok(new ApiResponse<TaxDto>(true, data));
}
[HttpPatch("{id}")]
public async Task<IActionResult> Update(
string cafeId,
string id,
[FromBody] UpdateTaxRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
var data = await _taxService.UpdateAsync(cafeId, id, request, cancellationToken);
if (data is null) return NotFoundError();
return Ok(new ApiResponse<TaxDto>(true, data));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
var deleted = await _taxService.DeleteAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
}
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.API.Services;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/terminals")]
public class TerminalsController : CafeApiControllerBase
{
private readonly ITerminalRegistryService _terminals;
public TerminalsController(ITerminalRegistryService terminals) => _terminals = terminals;
[HttpPost("register")]
public async Task<IActionResult> Register(
string cafeId,
[FromBody] RegisterTerminalRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var tier = tenant.PlanTier ?? PlanTier.Free;
var (allowed, code, message) = await _terminals.RegisterAsync(cafeId, tier, request.TerminalId, ct);
if (!allowed)
return StatusCode(403, new ApiResponse<object>(false, null, new ApiError(code!, message!)));
return Ok(new ApiResponse<object>(true, new { registered = true }));
}
[HttpGet]
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var list = await _terminals.ListAsync(cafeId, ct);
var max = PlanLimits.MaxTerminals(tenant.PlanTier ?? PlanTier.Free);
return Ok(new ApiResponse<object>(true, new { terminals = list, max }));
}
[HttpDelete("{terminalId}")]
public async Task<IActionResult> Revoke(
string cafeId,
string terminalId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
await _terminals.RevokeAsync(cafeId, terminalId, ct);
return Ok(new ApiResponse<object>(true, new { revoked = true }));
}
}
public record RegisterTerminalRequest(string TerminalId);
@@ -0,0 +1,104 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>Public website endpoints — blog, comments, demo requests.</summary>
[AllowAnonymous]
[ApiController]
[Route("api/public/website")]
public class WebsiteContentController(IWebsiteService website) : ControllerBase
{
// ── Blog posts ────────────────────────────────────────────────────────
[HttpGet("posts")]
public async Task<IActionResult> GetPosts(
[FromQuery] string locale = "fa",
[FromQuery] int page = 1,
[FromQuery] int limit = 12,
CancellationToken ct = default)
{
limit = Math.Clamp(limit, 1, 50);
page = Math.Max(page, 1);
var (posts, total) = await website.GetPostsAsync(locale, page, limit, ct);
return Ok(new ApiResponse<object>(true, new { Posts = posts, Total = total, Page = page, Limit = limit }));
}
[HttpGet("posts/{slug}")]
public async Task<IActionResult> GetPost(string slug,
[FromQuery] string locale = "fa",
CancellationToken ct = default)
{
var post = await website.GetPostAsync(slug, locale, ct);
if (post is null) return NotFound(new ApiResponse<object>(false, null,
new ApiError("POST_NOT_FOUND", "Blog post not found.")));
return Ok(new ApiResponse<object>(true, post));
}
// ── Comments ──────────────────────────────────────────────────────────
[HttpGet("posts/{slug}/comments")]
public async Task<IActionResult> GetComments(string slug, CancellationToken ct = default)
{
var comments = await website.GetCommentsAsync(slug, ct);
return Ok(new ApiResponse<object>(true, comments));
}
[HttpPost("posts/{slug}/comments")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> PostComment(string slug,
[FromBody] PostCommentRequest req,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(req.AuthorName) || string.IsNullOrWhiteSpace(req.Content))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION", "AuthorName and Content are required.")));
if (req.Content.Length > 2000)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION", "Comment too long (max 2000 chars).")));
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
try
{
var comment = await website.AddCommentAsync(slug, req.AuthorName, req.Email, req.Content, ip, ct);
return Ok(new ApiResponse<object>(true, comment));
}
catch (InvalidOperationException)
{
return NotFound(new ApiResponse<object>(false, null,
new ApiError("POST_NOT_FOUND", "Blog post not found.")));
}
}
// ── Demo requests ─────────────────────────────────────────────────────
[HttpPost("demo-requests")]
[EnableRateLimiting("public-write")]
public async Task<IActionResult> CreateDemoRequest(
[FromBody] CreateDemoRequestBody req,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(req.ContactName) ||
string.IsNullOrWhiteSpace(req.BusinessName) ||
string.IsNullOrWhiteSpace(req.Phone))
{
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("VALIDATION", "ContactName, BusinessName, and Phone are required.")));
}
var result = await website.CreateDemoRequestAsync(
req.ContactName, req.BusinessName, req.Phone,
req.Email, req.BranchCount ?? "1", req.Notes, req.Source ?? "website", ct);
return Ok(new ApiResponse<object>(true, result));
}
}
public record PostCommentRequest(string AuthorName, string? Email, string Content);
public record CreateDemoRequestBody(
string ContactName, string BusinessName, string Phone,
string? Email, string? BranchCount, string? Notes, string? Source);
@@ -0,0 +1,89 @@
using System.Threading.RateLimiting;
using Meezi.API.Security;
using Microsoft.AspNetCore.RateLimiting;
namespace Meezi.API.Extensions;
public static class SecurityExtensions
{
public static IServiceCollection AddMeeziSecurity(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<AbuseProtectionOptions>(configuration.GetSection(AbuseProtectionOptions.SectionName));
services.AddHttpClient("turnstile");
services.AddSingleton<IAbuseProtectionService, AbuseProtectionService>();
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.ContentType = "application/json";
await context.HttpContext.Response.WriteAsJsonAsync(
new
{
success = false,
error = new
{
code = "RATE_LIMITED",
message = "Too many requests. Please wait and try again."
}
},
token);
};
options.AddPolicy("public-read", httpContext =>
{
var ip = ClientIpResolver.GetClientIp(httpContext);
var limit = configuration.GetValue("Security:RateLimits:PublicReadsPerIpPerMinute", 120);
return RateLimitPartition.GetSlidingWindowLimiter(
$"read:{ip}",
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = limit,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
options.AddPolicy("public-write", httpContext =>
{
var ip = ClientIpResolver.GetClientIp(httpContext);
var limit = configuration.GetValue("Security:RateLimits:PublicWritesPerIpPerMinute", 20);
return RateLimitPartition.GetSlidingWindowLimiter(
$"write:{ip}",
_ => new SlidingWindowRateLimiterOptions
{
PermitLimit = limit,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
options.AddPolicy("auth-otp", httpContext =>
{
var ip = ClientIpResolver.GetClientIp(httpContext);
var limit = configuration.GetValue("Security:RateLimits:AuthOtpPerIpPerHour", 15);
return RateLimitPartition.GetFixedWindowLimiter(
$"otp:{ip}",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = limit,
Window = TimeSpan.FromHours(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
});
return services;
}
public static void UseMeeziSecurity(this WebApplication app)
{
app.UseRateLimiter();
}
}
@@ -0,0 +1,246 @@
using System.Text;
using System.Text.Json.Serialization;
using FluentValidation;
using Hangfire;
using Hangfire.MemoryStorage;
using Hangfire.PostgreSql;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Meezi.API.Hubs;
using Meezi.API.Configuration;
using Meezi.API.Jobs;
using Meezi.API.Services;
using Meezi.API.Services.Delivery;
using Meezi.Infrastructure.Services.Platform;
using Meezi.API.Services.Printing;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure;
using Serilog;
using StackExchange.Redis;
namespace Meezi.API.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMeeziServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpContextAccessor();
services.AddMeeziSecurity(configuration);
services.AddInfrastructure(configuration);
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IConsumerAuthService, ConsumerAuthService>();
services.AddScoped<IConsumerOrdersService, ConsumerOrdersService>();
services.AddScoped<IKitchenStationService, KitchenStationService>();
services.AddScoped<INotificationInboxService, NotificationInboxService>();
services.AddScoped<IOrderNotificationService, OrderNotificationService>();
services.AddScoped<IWebsiteService, Meezi.Infrastructure.Services.WebsiteService>();
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddSingleton<IRefreshTokenStore, RedisRefreshTokenStore>();
services.AddScoped<IPlanLimitChecker, PlanLimitChecker>();
services.AddScoped<IMenuService, MenuService>();
services.AddScoped<IBranchMenuService, BranchMenuService>();
services.AddScoped<IBranchIdentityService, BranchIdentityService>();
services.AddScoped<ITableService, TableService>();
services.AddScoped<IOrderService, OrderService>();
services.AddScoped<IKdsNotifier, KdsNotifier>();
services.AddScoped<ICustomerService, CustomerService>();
services.AddScoped<ICouponService, CouponService>();
services.AddScoped<ITaxService, TaxService>();
services.AddSingleton<IMediaStorageService, MediaStorageService>();
services.Configure<MenuAi3dOptions>(configuration.GetSection(MenuAi3dOptions.SectionName));
services.AddHttpClient("MenuAi3d");
services.AddHttpClient("OpenAi");
services.AddScoped<IOpenAiChatService, OpenAiChatService>();
services.AddScoped<ICoffeeAdvisorService, CoffeeAdvisorService>();
services.AddScoped<IMenuAi3dGenerationService, MenuAi3dGenerationService>();
services.AddScoped<IBranchLifecycleService, BranchLifecycleService>();
services.AddSingleton<ITerminalRegistryService, TerminalRegistryService>();
services.AddScoped<IInventoryService, InventoryService>();
services.AddScoped<ILoyaltyService, LoyaltyService>();
services.AddScoped<ISmsMarketingService, SmsMarketingService>();
services.AddScoped<IHrService, HrService>();
services.AddScoped<IReportService, ReportService>();
services.AddScoped<IDailyReportService, DailyReportService>();
services.AddScoped<IPublicService, PublicService>();
services.AddScoped<IReviewService, ReviewService>();
services.AddScoped<IBillingService, BillingService>();
services.AddScoped<IBillingPaymentOrchestrator, BillingPaymentOrchestrator>();
services.Configure<DeliveryPlatformsOptions>(configuration.GetSection(DeliveryPlatformsOptions.SectionName));
services.PostConfigure<DeliveryPlatformsOptions>(opts =>
{
if (string.IsNullOrEmpty(opts.Snappfood.WebhookSecret))
opts.Snappfood.WebhookSecret = configuration["Snappfood:WebhookSecret"] ?? "";
if (string.IsNullOrEmpty(opts.Snappfood.ApiKey))
opts.Snappfood.ApiKey = configuration["Snappfood:ApiKey"] ?? "";
if (string.IsNullOrEmpty(opts.Snappfood.ApiBaseUrl))
opts.Snappfood.ApiBaseUrl = configuration["Snappfood:ApiBaseUrl"] ?? "";
});
services.AddScoped<IWebhookSignatureService, WebhookSignatureService>();
services.AddScoped<IOrderNormalizer, OrderNormalizer>();
services.AddScoped<ICommissionCalculator, CommissionCalculator>();
services.AddScoped<IDeliveryOrderProcessor, DeliveryOrderProcessor>();
services.AddScoped<IDeliveryWebhookIngressService, DeliveryWebhookIngressService>();
services.AddScoped<IDeliveryStatusSyncService, DeliveryStatusSyncService>();
services.AddScoped<IDeliveryFinanceReportService, DeliveryFinanceReportService>();
services.AddScoped<ProcessDeliveryOrderJob>();
services.AddScoped<ISnappfoodWebhookService, SnappfoodWebhookService>();
services.AddScoped<IReservationService, ReservationService>();
services.AddScoped<IQueueService, QueueService>();
services.AddScoped<IShiftService, ShiftService>();
services.AddScoped<IExpenseService, ExpenseService>();
services.AddScoped<ReceiptBuilder>();
services.AddScoped<IPrinterService, NetworkPrinterService>();
services.AddHttpClient(nameof(PosDeviceService));
services.AddScoped<IPosDeviceService, PosDeviceService>();
services.AddScoped<SubscriptionRenewalReminderJob>();
services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
services.AddSignalR();
services.AddValidatorsFromAssemblyContaining<Program>();
var jwtKey = configuration["Jwt:Key"] ?? "meezi-dev-secret-key-min-32-chars!!";
var jwtIssuer = configuration["Jwt:Issuer"] ?? "meezi";
var jwtAudience = configuration["Jwt:Audience"] ?? "meezi";
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
context.Token = accessToken;
return Task.CompletedTask;
}
};
});
services.AddAuthorization();
var isTesting = configuration.GetValue<bool>("Testing:Enabled");
var redisConnection = configuration.GetConnectionString("Redis") ?? "localhost:6379";
if (isTesting)
{
services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect($"{redisConnection},abortConnect=false,connectTimeout=500"));
}
else
{
services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(redisConnection));
}
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
services.AddHangfire(config =>
{
config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings();
if (isTesting)
config.UseMemoryStorage();
else
config.UsePostgreSqlStorage(options => options.UseNpgsqlConnection(connectionString));
});
if (!isTesting)
services.AddHangfireServer();
services.AddCors(options =>
{
options.AddPolicy("MeeziCors", policy =>
{
var origins = configuration.GetSection("Cors:Origins").Get<string[]>()
?? ["http://localhost:3000"];
policy.WithOrigins(origins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
return services;
}
public static WebApplication ConfigureMeeziPipeline(this WebApplication app)
{
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
var uploadsPath = Path.Combine(app.Environment.ContentRootPath, "uploads");
Directory.CreateDirectory(uploadsPath);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsPath),
RequestPath = "/uploads"
});
app.UseCors("MeeziCors");
app.UseRouting();
app.UseMeeziSecurity();
app.UseAuthentication();
app.UseMiddleware<Middleware.TenantMiddleware>();
app.UseMiddleware<Middleware.PlanLimitMiddleware>();
app.UseAuthorization();
app.MapControllers();
app.MapHub<KdsHub>("/hubs/kds");
app.MapHub<GuestOrderHub>("/hubs/guest-order");
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
if (!app.Configuration.GetValue<bool>("Testing:Enabled"))
{
app.UseHangfireDashboard("/hangfire");
RecurringJob.AddOrUpdate<SubscriptionRenewalReminderJob>(
"subscription-renewal-reminder",
job => job.ExecuteAsync(),
Cron.Daily(6));
RecurringJob.AddOrUpdate<GenerateYesterdayReportsJob>(
"daily-reports-yesterday",
job => job.ExecuteAsync(),
"5 0 * * *",
new RecurringJobOptions { TimeZone = IranCalendar.TimeZone });
RecurringJob.AddOrUpdate<BranchPermanentDeleteJob>(
"branch-permanent-delete",
job => job.ExecuteAsync(),
Cron.Hourly);
}
return app;
}
}
+1
View File
@@ -0,0 +1 @@
+33
View File
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Hubs;
[AllowAnonymous]
public class GuestOrderHub : Hub
{
public static string OrderGroup(string orderId) => $"guest-order:{orderId}";
public async Task JoinOrder(string orderId, string trackingToken)
{
if (string.IsNullOrWhiteSpace(orderId) || string.IsNullOrWhiteSpace(trackingToken))
throw new HubException("Invalid order.");
var db = Context.GetHttpContext()?.RequestServices.GetRequiredService<AppDbContext>();
if (db is null)
throw new HubException("Unavailable.");
var valid = await db.Orders.AsNoTracking()
.AnyAsync(o => o.Id == orderId && o.GuestTrackingToken == trackingToken);
if (!valid)
throw new HubException("Forbidden.");
await Groups.AddToGroupAsync(Context.ConnectionId, OrderGroup(orderId));
}
public Task LeaveOrder(string orderId) =>
Groups.RemoveFromGroupAsync(Context.ConnectionId, OrderGroup(orderId));
}
+23
View File
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Meezi.Core.Constants;
namespace Meezi.API.Hubs;
[Authorize]
public class KdsHub : Hub
{
public static string GroupName(string cafeId) => $"cafe:{cafeId}";
public async Task JoinCafe(string cafeId)
{
var claimCafeId = Context.User?.FindFirst(MeeziClaimTypes.CafeId)?.Value;
if (string.IsNullOrEmpty(claimCafeId) || claimCafeId != cafeId)
throw new HubException("Forbidden");
await Groups.AddToGroupAsync(Context.ConnectionId, GroupName(cafeId));
}
public Task LeaveCafe(string cafeId) =>
Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(cafeId));
}
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,26 @@
using Meezi.API.Services;
namespace Meezi.API.Jobs;
public class BranchPermanentDeleteJob
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<BranchPermanentDeleteJob> _logger;
public BranchPermanentDeleteJob(
IServiceScopeFactory scopeFactory,
ILogger<BranchPermanentDeleteJob> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
public async Task ExecuteAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
var lifecycle = scope.ServiceProvider.GetRequiredService<IBranchLifecycleService>();
var purged = await lifecycle.PurgeExpiredDeletionsAsync();
if (purged > 0)
_logger.LogInformation("Permanently deleted {Count} expired branches", purged);
}
}
@@ -0,0 +1,61 @@
using Meezi.API.Services;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Jobs;
public class GenerateYesterdayReportsJob
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<GenerateYesterdayReportsJob> _logger;
public GenerateYesterdayReportsJob(
IServiceScopeFactory scopeFactory,
ILogger<GenerateYesterdayReportsJob> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
public async Task ExecuteAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var reports = scope.ServiceProvider.GetRequiredService<IDailyReportService>();
var reportDate = IranCalendar.TodayInIran.AddDays(-1);
var branches = await db.Branches
.Where(b => b.IsActive && b.DeletedAt == null)
.Select(b => new { b.Id, b.CafeId })
.ToListAsync();
var success = 0;
var failed = 0;
foreach (var branch in branches)
{
try
{
await reports.GenerateReportAsync(branch.CafeId, branch.Id, reportDate);
success++;
}
catch (Exception ex)
{
failed++;
_logger.LogWarning(
ex,
"Failed daily report for cafe {CafeId} branch {BranchId} date {Date}",
branch.CafeId,
branch.Id,
reportDate);
}
}
_logger.LogInformation(
"Yesterday daily reports ({Date}): {Success} ok, {Failed} failed",
reportDate,
success,
failed);
}
}
@@ -0,0 +1,34 @@
using Hangfire;
using Meezi.Core.Delivery;
using Meezi.API.Services.Delivery;
namespace Meezi.API.Jobs;
public class ProcessDeliveryOrderJob
{
private readonly IDeliveryOrderProcessor _processor;
private readonly ILogger<ProcessDeliveryOrderJob> _logger;
public ProcessDeliveryOrderJob(
IDeliveryOrderProcessor processor,
ILogger<ProcessDeliveryOrderJob> logger)
{
_processor = processor;
_logger = logger;
}
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [10, 60, 300])]
public async Task ExecuteAsync(
string webhookLogId,
UnifiedDeliveryOrder unified,
CancellationToken cancellationToken)
{
var result = await _processor.ProcessAsync(webhookLogId, unified, cancellationToken);
if (!result.Success && result.ErrorCode is not null)
_logger.LogWarning(
"Delivery process failed {Code}: {Message} (log {LogId})",
result.ErrorCode,
result.Message,
webhookLogId);
}
}
@@ -0,0 +1,60 @@
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Jobs;
public class SubscriptionRenewalReminderJob
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SubscriptionRenewalReminderJob> _logger;
public SubscriptionRenewalReminderJob(
IServiceScopeFactory scopeFactory,
ILogger<SubscriptionRenewalReminderJob> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
public async Task ExecuteAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var sms = scope.ServiceProvider.GetRequiredService<ISmsService>();
var windowStart = DateTime.UtcNow.Date;
var windowEnd = windowStart.AddDays(3);
var cafes = await db.Cafes
.Where(c => c.PlanTier != PlanTier.Free
&& c.PlanExpiresAt != null
&& c.PlanExpiresAt >= windowStart
&& c.PlanExpiresAt <= windowEnd)
.ToListAsync();
foreach (var cafe in cafes)
{
var ownerPhone = await db.Employees
.Where(e => e.CafeId == cafe.Id && e.Role == EmployeeRole.Owner)
.Select(e => e.Phone)
.FirstOrDefaultAsync();
if (string.IsNullOrEmpty(ownerPhone)) continue;
var message =
$"میزی: اشتراک {cafe.PlanTier} شما تا {cafe.PlanExpiresAt:yyyy-MM-dd} منقضی می‌شود. از تنظیمات داشبورد تمدید کنید.";
try
{
await sms.SendMessageAsync(ownerPhone, message);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Renewal SMS failed for cafe {CafeId}", cafe.Id);
}
}
_logger.LogInformation("Subscription renewal reminders sent for {Count} cafes", cafes.Count);
}
}
+41
View File
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>Meezi.API</RootNamespace>
<AssemblyName>Meezi.API</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" />
<PackageReference Include="EPPlus" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="Hangfire.AspNetCore" />
<PackageReference Include="Hangfire.MemoryStorage" />
<PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="QRCoder" />
<PackageReference Include="QuestPDF" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="StackExchange.Redis" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\data\menu-image-manifest.json" Link="data\menu-image-manifest.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\data\demo-credentials.json" Link="data\demo-credentials.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Meezi.Core\Meezi.Core.csproj" />
<ProjectReference Include="..\Meezi.Infrastructure\Meezi.Infrastructure.csproj" />
<ProjectReference Include="..\Meezi.Shared\Meezi.Shared.csproj" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@Meezi.API_HostAddress = http://localhost:5011
GET {{Meezi.API_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,64 @@
using System.Text.Json;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Middleware;
public class PlanLimitMiddleware
{
private static readonly string[] SkipPrefixes =
[
"/api/auth",
"/api/customers/me",
"/api/admin",
"/hubs/guest-order",
"/api/public",
"/api/q/",
"/api/webhooks",
"/api/billing/verify",
"/health",
"/swagger",
"/hangfire"
];
private readonly RequestDelegate _next;
public PlanLimitMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, ITenantContext tenant, IPlanLimitChecker planLimitChecker)
{
if (ShouldSkip(context.Request.Path))
{
await _next(context);
return;
}
if (context.User.Identity?.IsAuthenticated == true)
{
var (allowed, code, message) = await planLimitChecker.CheckAsync(context, tenant, context.RequestAborted);
if (!allowed)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
var payload = new ApiResponse<object>(false, null, new ApiError(code!, message!));
await context.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
return;
}
}
await _next(context);
}
private static bool ShouldSkip(PathString path)
{
var value = path.Value ?? string.Empty;
return SkipPrefixes.Any(p => value.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,154 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Middleware;
public class TenantMiddleware
{
private static readonly string[] PublicPrefixes =
[
"/api/auth",
"/api/public",
"/api/q/",
"/api/webhooks",
"/api/billing/verify",
"/hubs/guest-order",
"/health",
"/swagger",
"/hangfire"
];
private readonly RequestDelegate _next;
private readonly ILogger<TenantMiddleware> _logger;
public TenantMiddleware(RequestDelegate next, ILogger<TenantMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(
HttpContext context,
ITenantContext tenant,
IBranchContext branchContext,
AppDbContext db)
{
if (IsPublicPath(context.Request.Path))
{
await _next(context);
return;
}
if (context.User.Identity?.IsAuthenticated != true)
{
await WriteUnauthorizedAsync(context, "UNAUTHORIZED", "Authentication required.");
return;
}
var actor = context.User.FindFirst(MeeziClaimTypes.Actor)?.Value;
var pathValue = context.Request.Path.Value ?? string.Empty;
if (actor == MeeziActorKinds.Consumer)
{
if (pathValue.StartsWith("/api/customers/me", StringComparison.OrdinalIgnoreCase))
{
await _next(context);
return;
}
await WriteForbiddenAsync(context, "FORBIDDEN", "Consumer access is limited to account endpoints.");
return;
}
if (tenant is TenantContext scopedTenant)
{
scopedTenant.UserId = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? context.User.FindFirst("sub")?.Value;
scopedTenant.Language = context.User.FindFirst(MeeziClaimTypes.Language)?.Value ?? "fa";
}
var cafeId = context.User.FindFirst(MeeziClaimTypes.CafeId)?.Value;
if (string.IsNullOrEmpty(cafeId))
{
_logger.LogWarning("Authenticated request missing cafeId claim for {Path}", context.Request.Path);
await WriteUnauthorizedAsync(context, "UNAUTHORIZED", "Cafe context is missing.");
return;
}
var cafeSuspended = await db.Cafes
.AsNoTracking()
.AnyAsync(c => c.Id == cafeId && c.IsSuspended, context.RequestAborted);
if (cafeSuspended)
{
await WriteForbiddenAsync(context, "CAFE_SUSPENDED", "This cafe account is suspended. Contact Meezi support.");
return;
}
if (tenant is TenantContext scopedMerchant)
{
scopedMerchant.CafeId = cafeId;
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value;
if (Enum.TryParse<EmployeeRole>(roleClaim, ignoreCase: true, out var role))
scopedMerchant.Role = role;
var planClaim = context.User.FindFirst(MeeziClaimTypes.PlanTier)?.Value;
if (Enum.TryParse<PlanTier>(planClaim, ignoreCase: true, out var plan))
scopedMerchant.PlanTier = plan;
var branchIdClaim = context.User.FindFirst(MeeziClaimTypes.BranchId)?.Value;
if (!string.IsNullOrEmpty(branchIdClaim))
{
var branchValid = await db.Branches.AnyAsync(
b => b.Id == branchIdClaim && b.CafeId == cafeId && b.IsActive,
context.RequestAborted);
if (branchValid)
scopedMerchant.BranchId = branchIdClaim;
else
_logger.LogWarning("Ignoring invalid or inactive branchId claim for cafe {CafeId}", cafeId);
}
}
if (branchContext is BranchContext scopedBranch)
{
scopedBranch.CafeId = cafeId;
if (tenant is TenantContext scopedTenantBranch && !string.IsNullOrEmpty(scopedTenantBranch.BranchId))
scopedBranch.BranchId = scopedTenantBranch.BranchId;
}
await _next(context);
}
private static bool IsPublicPath(PathString path)
{
var value = path.Value ?? string.Empty;
return PublicPrefixes.Any(prefix =>
value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}
private static async Task WriteUnauthorizedAsync(HttpContext context, string code, string message)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json";
var payload = new ApiResponse<object>(false, null, new ApiError(code, message));
await context.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
private static async Task WriteForbiddenAsync(HttpContext context, string code, string message)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/json";
var payload = new ApiResponse<object>(false, null, new ApiError(code, message));
await context.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Meezi.API.Models.Auth;
public record SendOtpRequest(string Phone);
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
public record RefreshTokenRequest(string RefreshToken);
public record AuthTokenResponse(
string AccessToken,
string RefreshToken,
DateTime ExpiresAt,
string UserId,
string CafeId,
string Role,
string PlanTier,
string Language,
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant,
string? BranchId = null);
public record SendOtpResponse(bool Sent, int ExpiresInSeconds);
@@ -0,0 +1,27 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Billing;
public record SubscribeRequest(PlanTier PlanTier, int Months, string? PaymentMethod = null);
public record PaymentMethodDto(string Id, string DisplayNameFa, bool IsDefault);
public record SubscribeResponse(string PaymentId, string PaymentUrl);
public record BillingStatusDto(
PlanTier PlanTier,
DateTime? PlanExpiresAt,
int OrdersToday,
int? OrdersDailyLimit,
int CustomersCount,
int? CustomersLimit,
int SmsUsedThisMonth,
int SmsMonthlyLimit,
bool Menu3dEnabled,
bool MenuAi3dEnabled,
int MenuAi3dUsedThisMonth,
int MenuAi3dMonthlyLimit,
bool DiscoverProfileEnabled,
bool IsPlanExpired);
public record BillingVerifyResult(bool Success, string RedirectUrl);
@@ -0,0 +1,35 @@
namespace Meezi.API.Models.Branches;
public record BranchDto(
string Id,
string Name,
string? Address,
string? City,
string? Phone,
bool IsActive,
string? LoginPhone = null,
string? ManagerName = null,
bool IsPendingDeletion = false,
DateTime? DeletedAt = null,
DateTime? ScheduledPermanentDeleteAt = null,
int? DaysUntilPermanentDelete = null);
public record CreateBranchRequest(
string Name,
string LoginPhone,
string? ManagerName,
string? Address,
string? City,
string? Phone);
public record PatchBranchRequest(
string? Name,
string? Address,
string? City,
string? Phone,
bool? IsActive,
string? LogoUrl,
string? WelcomeText,
string? AccentColor,
string? WifiPassword,
decimal? TaxRate);
@@ -0,0 +1,31 @@
namespace Meezi.API.Models.Cafes;
public record CafeSettingsDto(
string Id,
string Name,
string Slug,
string? Phone,
string? Address,
string? City,
string? Description,
string? LogoUrl,
string? CoverImageUrl,
string? SnappfoodVendorId,
string PlanTier,
DateTime? PlanExpiresAt,
CafeThemeDto Theme,
decimal DefaultTaxRate,
bool AllowBranchTaxOverride);
public record PatchCafeSettingsRequest(
string? Name,
string? Phone,
string? Address,
string? City,
string? Description,
string? LogoUrl,
string? CoverImageUrl,
string? SnappfoodVendorId,
CafeThemeDto? Theme,
decimal? DefaultTaxRate,
bool? AllowBranchTaxOverride);
@@ -0,0 +1,30 @@
namespace Meezi.API.Models.Cafes;
public record CafeThemeCustomColorsDto(
string? Primary,
string? Secondary,
string? Accent,
string? Background,
string? Surface,
string? Text,
string? TextMuted,
string? Destructive,
string? Success,
int? PrimaryOpacity = null,
int? SecondaryOpacity = null,
int? AccentOpacity = null,
int? BackgroundOpacity = null,
int? SurfaceOpacity = null,
int? TextOpacity = null,
int? TextMutedOpacity = null,
int? DestructiveOpacity = null,
int? SuccessOpacity = null);
public record CafeThemeDto(
string PaletteId,
string PanelStyle,
string MenuStyle,
string MenuTexture,
string Density,
string Radius,
CafeThemeCustomColorsDto? Custom);
@@ -0,0 +1,23 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Consumer;
public record ConsumerAuthTokenResponse(
string AccessToken,
string RefreshToken,
DateTime ExpiresAt,
string UserId,
string Phone,
string Language);
public record ConsumerOrderHistoryDto(
string Id,
string CafeId,
string CafeName,
string CafeSlug,
OrderStatus Status,
decimal Total,
int DisplayNumber,
DateTime CreatedAt,
string? TableNumber,
int ItemCount);
+50
View File
@@ -0,0 +1,50 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Crm;
public record CouponDto(
string Id,
string Code,
CouponType Type,
decimal Value,
decimal? MinOrderAmount,
decimal? MaxDiscount,
int? UsageLimit,
int UsedCount,
CustomerGroup? TargetGroup,
DateTime? StartsAt,
DateTime? ExpiresAt,
bool IsActive);
public record CreateCouponRequest(
string Code,
CouponType Type,
decimal Value,
decimal? MinOrderAmount,
decimal? MaxDiscount,
int? UsageLimit,
CustomerGroup? TargetGroup,
DateTime? StartsAt,
DateTime? ExpiresAt,
bool IsActive = true);
public record ValidateCouponRequest(string Code, decimal Subtotal);
public record ValidateCouponResult(
string CouponId,
string Code,
CouponType Type,
decimal Value,
decimal DiscountAmount);
public record UpdateCouponRequest(
string? Code,
CouponType? Type,
decimal? Value,
decimal? MinOrderAmount,
decimal? MaxDiscount,
int? UsageLimit,
CustomerGroup? TargetGroup,
DateTime? StartsAt,
DateTime? ExpiresAt,
bool? IsActive);
+31
View File
@@ -0,0 +1,31 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Crm;
public record CustomerDto(
string Id,
string Name,
string Phone,
string? NationalId,
string? BirthDateJalali,
CustomerGroup Group,
int LoyaltyPoints,
string? ReferredBy,
DateTime CreatedAt);
public record CreateCustomerRequest(
string Name,
string Phone,
string? NationalId,
string? BirthDateJalali,
CustomerGroup Group = CustomerGroup.Regular,
string? ReferredBy = null);
public record UpdateCustomerRequest(
string? Name,
string? Phone,
string? NationalId,
string? BirthDateJalali,
CustomerGroup? Group,
int? LoyaltyPoints,
string? ReferredBy);
+12
View File
@@ -0,0 +1,12 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Crm;
public record SendSmsCampaignRequest(
string Message,
CustomerGroup? TargetGroup,
IReadOnlyList<string>? Phones);
public record SmsCampaignResult(int SentCount, int FailedCount);
public record SmsUsageDto(int UsedThisMonth, int MonthlyLimit, string Month);
@@ -0,0 +1,20 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Delivery;
public record PlatformRevenueDto(
DeliveryPlatform Platform,
string PlatformName,
int OrderCount,
decimal GrossRevenue,
decimal Commission,
decimal NetRevenue);
public record DeliveryRevenueReportDto(
string PeriodLabel,
DateTime UtcFrom,
DateTime UtcTo,
IReadOnlyList<PlatformRevenueDto> Platforms,
decimal TotalGross,
decimal TotalCommission,
decimal TotalNet);
@@ -0,0 +1,31 @@
namespace Meezi.API.Models.Discover;
public record CafeDiscoverProfileDto(
IReadOnlyList<string> Themes,
string? Size,
string? Floors,
IReadOnlyList<string> Vibes,
IReadOnlyList<string> Occasions,
IReadOnlyList<string> SpaceFeatures,
string? NoiseLevel,
string? PriceTier);
public record UpsertCafeDiscoverProfileRequest(
IReadOnlyList<string>? Themes,
string? Size,
string? Floors,
IReadOnlyList<string>? Vibes,
IReadOnlyList<string>? Occasions,
IReadOnlyList<string>? SpaceFeatures,
string? NoiseLevel,
string? PriceTier);
public record DiscoverProfileTaxonomyDto(
IReadOnlyList<string> Themes,
IReadOnlyList<string> Sizes,
IReadOnlyList<string> Floors,
IReadOnlyList<string> Vibes,
IReadOnlyList<string> Occasions,
IReadOnlyList<string> SpaceFeatures,
IReadOnlyList<string> NoiseLevels,
IReadOnlyList<string> PriceTiers);
@@ -0,0 +1,25 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Expenses;
public record CreateExpenseRequest(
string BranchId,
string? ShiftId,
ExpenseCategory Category,
decimal Amount,
string? Note,
string? ReceiptImageUrl);
public record ExpenseDto(
string Id,
string CafeId,
string BranchId,
string? ShiftId,
ExpenseCategory Category,
decimal Amount,
string? Note,
string? ReceiptImageUrl,
string CreatedByUserId,
DateTime CreatedAt);
public record ExpenseListResult(IReadOnlyList<ExpenseDto> Items, int Total);
+61
View File
@@ -0,0 +1,61 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Hr;
public record EmployeeSummaryDto(
string Id,
string Name,
string Phone,
EmployeeRole Role,
decimal BaseSalary);
public record AttendanceDto(
string Id,
string EmployeeId,
string EmployeeName,
DateOnly Date,
DateTime? ClockIn,
DateTime? ClockOut,
string? Notes);
public record ShiftDto(int DayOfWeek, ShiftType ShiftType);
public record UpsertShiftsRequest(IReadOnlyList<ShiftDto> Shifts);
public record LeaveRequestDto(
string Id,
string EmployeeId,
string EmployeeName,
DateOnly StartDate,
DateOnly EndDate,
string? Reason,
LeaveStatus Status,
string? ReviewedBy,
DateTime CreatedAt);
public record CreateLeaveRequest(
DateOnly StartDate,
DateOnly EndDate,
string? Reason);
public record ReviewLeaveRequest(LeaveStatus Status);
public record EmployeeSalaryDto(
string Id,
string EmployeeId,
string EmployeeName,
string MonthYear,
decimal BaseSalary,
decimal OvertimePay,
decimal Deductions,
decimal NetSalary,
bool IsPaid);
public record CreateSalaryRequest(
string EmployeeId,
string MonthYear,
decimal BaseSalary,
decimal OvertimePay,
decimal Deductions);
public record TodayShiftDto(ShiftType ShiftType, string Label);
@@ -0,0 +1,24 @@
namespace Meezi.API.Models.Kitchen;
public record KitchenStationDto(
string Id,
string? BranchId,
string Name,
string? PrinterIp,
int PrinterPort,
int SortOrder,
int CategoryCount);
public record CreateKitchenStationRequest(
string Name,
string? BranchId,
string? PrinterIp,
int PrinterPort = 9100,
int SortOrder = 0);
public record UpdateKitchenStationRequest(
string? Name,
string? BranchId,
string? PrinterIp,
int? PrinterPort,
int? SortOrder);
@@ -0,0 +1,29 @@
namespace Meezi.API.Models.Menu;
public record BranchMenuItemDto(
string Id,
string CategoryId,
string Name,
string? NameAr,
string? NameEn,
string? Description,
decimal MasterPrice,
decimal EffectivePrice,
decimal DiscountPercent,
string? ImageUrl,
string? VideoUrl,
string? Model3dUrl,
bool IsAvailable,
bool IsOverridden,
bool HasPriceOverride);
public record UpsertBranchMenuOverrideRequest(
bool IsAvailable,
decimal? PriceOverride);
public record BranchMenuOverrideDto(
string MenuItemId,
string BranchId,
bool IsAvailable,
decimal? PriceOverride,
DateTime UpdatedAt);
+86
View File
@@ -0,0 +1,86 @@
namespace Meezi.API.Models.Menu;
public record MenuCategoryDto(
string Id,
string Name,
string? NameAr,
string? NameEn,
int SortOrder,
string? TaxId,
decimal DiscountPercent,
string? Icon,
string? IconPresetId,
string? IconStyle,
string? ImageUrl,
bool IsActive,
string? KitchenStationId);
public record CreateMenuCategoryRequest(
string Name,
string? NameAr,
string? NameEn,
int SortOrder,
string? TaxId,
decimal DiscountPercent,
string? Icon = null,
string? IconPresetId = null,
string? IconStyle = null,
string? ImageUrl = null,
bool IsActive = true,
string? KitchenStationId = null);
public record UpdateMenuCategoryRequest(
string? Name,
string? NameAr,
string? NameEn,
int? SortOrder,
string? TaxId,
decimal? DiscountPercent,
string? Icon,
string? IconPresetId,
string? IconStyle,
string? ImageUrl,
bool? IsActive,
string? KitchenStationId);
public record MenuItemDto(
string Id,
string CategoryId,
string Name,
string? NameAr,
string? NameEn,
string? Description,
decimal Price,
decimal DiscountPercent,
string? ImageUrl,
string? VideoUrl,
string? Model3dUrl,
bool IsAvailable);
public record CreateMenuItemRequest(
string CategoryId,
string Name,
string? NameAr,
string? NameEn,
string? Description,
decimal Price,
decimal DiscountPercent = 0,
string? ImageUrl = null,
string? VideoUrl = null,
string? Model3dUrl = null,
bool IsAvailable = true);
public record UpdateMenuItemRequest(
string? CategoryId,
string? Name,
string? NameAr,
string? NameEn,
string? Description,
decimal? Price,
decimal? DiscountPercent,
string? ImageUrl,
string? VideoUrl,
string? Model3dUrl,
bool? IsAvailable);
public record UpdateMenuItemAvailabilityRequest(bool IsAvailable);
@@ -0,0 +1,19 @@
namespace Meezi.API.Models.Notifications;
public record CafeNotificationDto(
string Id,
string Type,
string Title,
string? Body,
string? ReferenceId,
string? TableNumber,
bool IsRead,
DateTime CreatedAt);
public record NotificationListDto(
IReadOnlyList<CafeNotificationDto> Items,
int UnreadCount);
public record MarkNotificationsReadRequest(
IReadOnlyList<string>? Ids,
bool All = false);
+81
View File
@@ -0,0 +1,81 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Orders;
public record OrderItemDto(
string Id,
string MenuItemId,
string MenuItemName,
int Quantity,
decimal UnitPrice,
string? Notes,
bool IsVoided = false,
DateTime? VoidedAt = null);
public record TransferTableRequest(string TargetTableId);
public record OrderDto(
string Id,
string CafeId,
string? BranchId,
string? TableId,
string? TableNumber,
string? GuestName,
string? GuestPhone,
string? CustomerName,
string? CustomerPhone,
string? CustomerId,
string? EmployeeId,
OrderType OrderType,
OrderSource Source,
OrderStatus Status,
decimal Subtotal,
decimal TaxTotal,
decimal DiscountAmount,
decimal Total,
decimal PaidAmount,
DateTime CreatedAt,
int DisplayNumber,
IReadOnlyList<OrderItemDto> Items,
IReadOnlyList<PaymentDto> Payments);
public record AppendOrderItemsRequest(IReadOnlyList<CreateOrderItemRequest> Items);
public record UpdateOrderSessionRequest(
string? GuestName,
string? GuestPhone,
string? CustomerId);
public record CreateOrderItemRequest(string MenuItemId, int Quantity, string? Notes);
public record CreateOrderRequest(
OrderType OrderType,
string? BranchId,
string? TableId,
string? ReservationId,
string? GuestName,
string? GuestPhone,
string? CustomerId,
string? CouponId,
IReadOnlyList<CreateOrderItemRequest> Items);
public record UpdateOrderStatusRequest(OrderStatus Status);
public record CreatePaymentRequest(PaymentMethod Method, decimal Amount, string? Reference);
public record RecordPaymentsRequest(
IReadOnlyList<CreatePaymentRequest> Payments,
int? LoyaltyPointsToRedeem = null);
public record PaymentDto(string Id, PaymentMethod Method, decimal Amount, PaymentStatus Status, string? Reference);
public record LiveOrderDto(
string Id,
int DisplayNumber,
OrderStatus Status,
string? TableNumber,
OrderType OrderType,
decimal Total,
DateTime CreatedAt,
IReadOnlyList<OrderItemDto> Items);
@@ -0,0 +1,36 @@
namespace Meezi.API.Models.Printing;
public record BranchPrintSettingsDto(
string BranchId,
string? ReceiptPrinterIp,
int? ReceiptPrinterPort,
string? KitchenPrinterIp,
int? KitchenPrinterPort,
int PaperWidthMm,
bool AutoCutEnabled,
string? ReceiptHeader,
string? ReceiptFooter,
string? WifiPassword,
string? PosDeviceIp,
int? PosDevicePort);
public record PatchBranchPrintSettingsRequest(
string? ReceiptPrinterIp,
int? ReceiptPrinterPort,
string? KitchenPrinterIp,
int? KitchenPrinterPort,
int? PaperWidthMm,
bool? AutoCutEnabled,
string? ReceiptHeader,
string? ReceiptFooter,
string? WifiPassword,
string? PosDeviceIp,
int? PosDevicePort);
public record PosPaymentRequest(string OrderId, decimal Amount);
public record PosPaymentResultDto(bool Sent, bool Skipped, string? Message);
public record TestPrintRequest(string PrinterIp, int Port = 9100);
public record PrintJobResultDto(bool Printed, string? ErrorCode, string? Message);
@@ -0,0 +1,9 @@
namespace Meezi.API.Models.Public;
public record CoffeeAdvisorRequest(string Purpose, string? CafeSlug = null);
public record CoffeeAdvisorPickDto(string Name, string Reason, string? MenuItemId);
public record CoffeeAdvisorResultDto(
string Summary,
IReadOnlyList<CoffeeAdvisorPickDto> Picks);
+187
View File
@@ -0,0 +1,187 @@
using Meezi.API.Models.Cafes;
using Meezi.API.Models.Discover;
using Meezi.API.Models.Orders;
using Meezi.Core.Enums;
namespace Meezi.API.Models.Public;
// ── Working hours (read-only, for public display) ────────────────────────────
public record DaySchedulePublicDto(bool IsOpen, string? Open, string? Close);
public record WorkingHoursPublicDto(
DaySchedulePublicDto? Sat,
DaySchedulePublicDto? Sun,
DaySchedulePublicDto? Mon,
DaySchedulePublicDto? Tue,
DaySchedulePublicDto? Wed,
DaySchedulePublicDto? Thu,
DaySchedulePublicDto? Fri);
// ── NLP parse result (returned by /api/public/discover/nlp-parse) ────────────
public record DiscoverNlpHintsDto(
IReadOnlyList<string> Themes,
IReadOnlyList<string> Vibes,
IReadOnlyList<string> Occasions,
IReadOnlyList<string> SpaceFeatures,
string? NoiseLevel,
string? PriceTier,
string? Size)
{
public static readonly DiscoverNlpHintsDto Empty =
new([], [], [], [], null, null, null);
}
public record CafeBadgePublicDto(string Key, string Label, string Icon);
public record CafeDiscoverDto(
string Id,
string Name,
string Slug,
string? City,
string? Address,
string? LogoUrl,
string? CoverImageUrl,
bool IsVerified,
double AverageRating,
int ReviewCount,
CafeDiscoverProfileDto DiscoverProfile,
IReadOnlyList<CafeBadgePublicDto> Badges,
IReadOnlyList<string> GalleryUrls,
bool IsOpenNow,
string? InstagramHandle,
string? WebsiteUrl,
double RelevanceScore);
public record CafePublicDto(
string Id,
string Name,
string? NameAr,
string? NameEn,
string Slug,
string? City,
string? Address,
string? Phone,
string? LogoUrl,
string? CoverImageUrl,
string? Description,
bool IsVerified,
double AverageRating,
int ReviewCount,
CafeDiscoverProfileDto DiscoverProfile,
IReadOnlyList<CafeBadgePublicDto> Badges,
IReadOnlyList<string> GalleryUrls,
bool IsOpenNow,
string? InstagramHandle,
string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours);
public record PublicMenuItemDto(
string Id,
string CategoryId,
string Name,
string? NameAr,
string? NameEn,
string? Description,
decimal Price,
decimal DiscountPercent,
string? ImageUrl,
string? VideoUrl,
string? Model3dUrl,
bool IsAvailable);
public record PublicMenuCategoryDto(
string Id,
string Name,
string? NameAr,
string? NameEn,
string? Icon,
string? IconPresetId,
string? IconStyle,
string? ImageUrl,
IReadOnlyList<PublicMenuItemDto> Items);
public record PublicMenuDto(
string CafeId,
string CafeName,
string Slug,
CafeThemeDto Theme,
IReadOnlyList<PublicMenuCategoryDto> Categories);
public record GuestCreateOrderRequest(
OrderType OrderType,
string? TableId,
string? GuestPhone,
string? GuestName,
string? CouponCode,
IReadOnlyList<CreateOrderItemRequest> Items,
string? CaptchaToken = null);
public record GuestOrderPlacedDto(
string OrderId,
OrderStatus Status,
decimal Total,
string? TableNumber);
public record PlaceGuestOrderRequest(
string TableId,
string? GuestName,
string? GuestPhone,
IReadOnlyList<CreateOrderItemRequest> Items,
string? CaptchaToken = null);
public record PublicSecurityConfigDto(
bool AbuseProtectionEnabled,
string? TurnstileSiteKey,
bool CaptchaRequired);
public record GuestQrOrderPlacedDto(
string OrderId,
string OrderNumber,
decimal TotalAmount,
int ItemCount,
OrderStatus Status,
string TrackingToken);
public record OrderTrackingStepDto(
string Key,
string LabelKey,
bool IsComplete,
bool IsCurrent);
public record OrderTrackDto(
string Id,
string OrderNumber,
OrderStatus Status,
string StatusLabelKey,
decimal Total,
string? TableNumber,
DateTime CreatedAt,
DateTime StatusUpdatedAt,
string TrackingToken,
IReadOnlyList<OrderTrackingStepDto> Steps,
IReadOnlyList<OrderItemDto> Items);
public record CreateReservationRequest(
string GuestName,
string GuestPhone,
DateOnly Date,
TimeOnly Time,
int PartySize,
string? Notes,
string? TableId,
string? CaptchaToken = null);
public record ReservationDto(
string Id,
string CafeId,
string? TableId,
string? TableNumber,
string GuestName,
string GuestPhone,
DateOnly Date,
TimeOnly Time,
int PartySize,
ReservationStatus Status,
string? Notes);
+22
View File
@@ -0,0 +1,22 @@
namespace Meezi.API.Models.Public;
public record CafeReviewDto(
string Id,
string AuthorName,
int Rating,
string? Comment,
string? OwnerReply,
DateTime CreatedAt,
IReadOnlyList<string> PhotoUrls,
bool IsHidden = false);
public record CreateCafeReviewRequest(
string AuthorName,
string? AuthorPhone,
int Rating,
string? Comment,
string? CaptchaToken = null);
public record ReplyCafeReviewRequest(string Reply);
public record HideCafeReviewRequest(bool IsHidden);
+27
View File
@@ -0,0 +1,27 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Queue;
public record QueueTicketDto(
string Id,
string? BranchId,
DateOnly ServiceDate,
int Number,
string? CustomerLabel,
string? OrderId,
QueueTicketStatus Status,
DateTime IssuedAt);
public record QueueBoardDto(
DateOnly ServiceDate,
int? NowServing,
int LastIssued,
int WaitingCount,
IReadOnlyList<QueueTicketDto> Tickets);
public record IssueQueueTicketRequest(
string? BranchId,
string? CustomerLabel,
string? OrderId);
public record UpdateQueueTicketStatusRequest(QueueTicketStatus Status);
@@ -0,0 +1,35 @@
namespace Meezi.API.Models.Reports;
public record TopProductSnapshotDto(string ProductId, string Name, int Quantity, decimal Revenue);
public record DailyReportSnapshotDto(
string Id,
string CafeId,
string BranchId,
string Date,
decimal TotalRevenue,
decimal CashRevenue,
decimal CardRevenue,
decimal CreditRevenue,
int TotalOrders,
decimal AvgOrderValue,
int TotalVoids,
decimal VoidAmount,
decimal TotalExpenses,
decimal NetIncome,
IReadOnlyList<TopProductSnapshotDto> TopProducts,
DateTime GeneratedAt);
public record DailyReportSummaryDto(
int Days,
decimal TotalRevenue,
decimal CashRevenue,
decimal CardRevenue,
decimal CreditRevenue,
int TotalOrders,
decimal AvgOrderValue,
int TotalVoids,
decimal VoidAmount,
decimal TotalExpenses,
decimal NetIncome,
IReadOnlyList<DailyReportSnapshotDto> ByBranch);
@@ -0,0 +1,26 @@
namespace Meezi.API.Models.Reports;
public record TopItemDto(string MenuItemId, string Name, int Quantity, decimal Revenue);
public record DailyReportDto(
string DateJalali,
int TotalOrders,
int NewCustomers,
int ReturningCustomers,
decimal Revenue,
decimal TaxTotal,
decimal DiscountTotal,
IReadOnlyList<TopItemDto> TopItems);
public record DailyBreakdownDto(string DateJalali, decimal Revenue, decimal Cost);
public record MonthlyReportDto(
string MonthJalali,
IReadOnlyList<DailyBreakdownDto> DailyBreakdown,
decimal TotalRevenue,
decimal TotalCosts,
decimal SalaryCosts,
decimal OtherCosts,
decimal NetProfit);
public record TrendDayDto(string DateJalali, decimal Revenue, decimal Cost);
+33
View File
@@ -0,0 +1,33 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Shifts;
public record ShiftDto(
string Id,
string CafeId,
string BranchId,
string OpenedByUserId,
string? ClosedByUserId,
DateTime OpenedAt,
DateTime? ClosedAt,
decimal OpeningCash,
decimal? ClosingCash,
decimal ExpectedCash,
decimal? Discrepancy,
ShiftStatus Status);
public record CashTransactionDto(
string Id,
string ShiftId,
string? BranchId,
CashTransactionType Type,
PaymentMethod Method,
decimal Amount,
string? ReferenceId,
string? Note,
string CreatedByUserId,
DateTime CreatedAt);
public record OpenShiftRequest(decimal OpeningCash);
public record CloseShiftRequest(decimal ClosingCash);
@@ -0,0 +1,14 @@
namespace Meezi.API.Models.Snappfood;
public record SnappfoodWebhookOrder(
string OrderId,
string VendorId,
string? CustomerName,
string? CustomerPhone,
decimal Total,
IReadOnlyList<SnappfoodWebhookItem> Items);
public record SnappfoodWebhookItem(
string Name,
int Quantity,
decimal UnitPrice);
+210
View File
@@ -0,0 +1,210 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Tables;
public record TableDto(
string Id,
string BranchId,
string? SectionId,
string? SectionName,
int SortOrder,
string Number,
int Capacity,
string? Floor,
string QrCode,
string QrCodeUrl,
string? ImageUrl,
string? VideoUrl,
bool IsActive);
public record CreateTableRequest(
string? BranchId,
string Number,
int Capacity = 4,
string? Floor = null,
bool IsActive = true);
public record CreateBranchTableRequest(
string Number,
int Capacity = 4,
string? Floor = null,
string? SectionId = null,
int SortOrder = 0,
bool IsActive = true);
public record PatchTableRequest(
string? Number,
int? Capacity,
string? Floor,
string? BranchId,
string? ImageUrl,
string? VideoUrl,
bool? IsActive,
bool? IsCleaning);
public record PatchBranchTableRequest(
string? Number,
int? Capacity,
string? Floor,
string? SectionId,
int? SortOrder,
string? ImageUrl,
string? VideoUrl,
bool? IsActive,
bool? IsCleaning);
public record TableSectionDto(
string Id,
string BranchId,
string Name,
int SortOrder,
bool IsActive,
int TableCount);
public record CreateTableSectionRequest(string Name, int SortOrder = 0);
public record PatchTableSectionRequest(
string? Name,
int? SortOrder,
bool? IsActive);
public record SetTableCleaningRequest(bool IsCleaning);
public record QrResolveResponse(
string CafeId,
string CafeSlug,
string TableId,
string TableNumber,
string TableName,
string BranchId,
string BranchName,
string CafeName,
string PrimaryColor,
string? LogoUrl,
string WelcomeText,
string? WifiPassword,
string? Address,
bool IsCleaning);
public record TableCurrentOrderSummary(
string OrderId,
OrderStatus Status,
decimal Total,
string? GuestLabel,
OrderSource? Source = null);
public record TableBoardDto(
string Id,
string BranchId,
string? SectionId,
string? SectionName,
int SortOrder,
string Number,
int Capacity,
string? Floor,
string QrCode,
string QrCodeUrl,
string? ImageUrl,
string? VideoUrl,
bool IsActive,
TableBoardStatus Status,
TableCurrentOrderSummary? CurrentOrder,
bool IsCleaning);
public record BranchTableOperationResult<T>(bool Success, T? Data, string? ErrorCode, string? Message);
+30
View File
@@ -0,0 +1,30 @@
namespace Meezi.API.Models.Tap30;
public record Tap30WebhookOrder(
string OrderId,
string VendorId,
Tap30Customer? Customer,
decimal Total,
string? PaymentMethod,
bool? IsPaid,
decimal? Commission,
string? DeliveryType,
int? EstimatedMinutes,
string? DriverName,
string? DriverPhone,
string? Status,
IReadOnlyList<Tap30WebhookItem>? Items);
public record Tap30Customer(
string? Name,
string? Phone,
string? Address,
double? Lat,
double? Lng);
public record Tap30WebhookItem(
string? Sku,
string Name,
int Quantity,
decimal UnitPrice,
string? Notes);
+23
View File
@@ -0,0 +1,23 @@
namespace Meezi.API.Models.Taxes;
public record TaxDto(
string Id,
string Name,
decimal Rate,
bool IsDefault,
bool IsRequired,
bool IsCompound);
public record CreateTaxRequest(
string Name,
decimal Rate,
bool IsDefault = false,
bool IsRequired = true,
bool IsCompound = false);
public record UpdateTaxRequest(
string? Name,
decimal? Rate,
bool? IsDefault,
bool? IsRequired,
bool? IsCompound);
+77
View File
@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Meezi.API.Extensions;
using Meezi.Infrastructure.Data;
using Serilog;
namespace Meezi.API;
public partial class Program
{
public static async Task Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateLogger();
var hostAborted = false;
try
{
var app = BuildWebApplication(args);
if (app.Configuration.GetValue<bool>("RUN_MIGRATIONS"))
{
await using var scope = app.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
await DatabaseSchemaPatches.ApplyAsync(db);
}
await PlatformDataSeeder.EnsureCatalogUpgradesAsync(app.Services);
if (!app.Configuration.GetValue<bool>("Testing:SkipSeed"))
{
await DevelopmentDataSeeder.SeedAsync(app.Services);
await PlatformDataSeeder.SeedAsync(app.Services);
}
await app.RunAsync();
}
catch (HostAbortedException)
{
hostAborted = true;
throw;
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
if (!hostAborted)
await Log.CloseAndFlushAsync();
}
}
public static WebApplication BuildWebApplication(
string[] args,
Action<WebApplicationBuilder>? configureBeforeServices = null,
Action<WebApplicationBuilder>? configureAfterServices = null)
{
var builder = WebApplication.CreateBuilder(args);
configureBeforeServices?.Invoke(builder);
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console());
builder.Services.AddMeeziServices(builder.Configuration);
configureAfterServices?.Invoke(builder);
var app = builder.Build();
app.ConfigureMeeziPipeline();
return app;
}
}
@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:18189",
"sslPort": 44310
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5011",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7208;http://localhost:5011",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More