feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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)))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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
Reference in New Issue
Block a user