feat(api): .NET 10 multi-tenant REST API

Full backend implementation:
- Multi-tenant cafe/restaurant management (menus, orders, tables, staff)
- POS order flow with ZarinPal and Snappfood payment integration
- OTP authentication via Kavenegar SMS
- QR digital menu with public discover/finder endpoints
- Customer loyalty, coupons, CRM
- PostgreSQL via EF Core, Redis for caching/sessions
- Background jobs, webhook handlers
- Full migration history

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
@@ -0,0 +1,39 @@
using FluentValidation;
using Meezi.API.Models.Auth;
using Meezi.Core.Utilities;
namespace Meezi.API.Validators;
public class SendOtpRequestValidator : AbstractValidator<SendOtpRequest>
{
public SendOtpRequestValidator()
{
RuleFor(x => x.Phone)
.NotEmpty()
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
.WithMessage("Invalid Iranian mobile number.");
}
}
public class VerifyOtpRequestValidator : AbstractValidator<VerifyOtpRequest>
{
public VerifyOtpRequestValidator()
{
RuleFor(x => x.Phone)
.NotEmpty()
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
.WithMessage("Invalid Iranian mobile number.");
RuleFor(x => x.Code)
.Must(OtpNormalizer.IsValidSixDigitCode)
.WithMessage("OTP must be 6 digits.");
}
}
public class RefreshTokenRequestValidator : AbstractValidator<RefreshTokenRequest>
{
public RefreshTokenRequestValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty();
}
}
@@ -0,0 +1,17 @@
using FluentValidation;
using Meezi.API.Models.Billing;
using Meezi.Core.Constants;
using Meezi.Core.Enums;
namespace Meezi.API.Validators;
public class SubscribeRequestValidator : AbstractValidator<SubscribeRequest>
{
public SubscribeRequestValidator()
{
RuleFor(x => x.PlanTier)
.Must(PlanPricing.IsBillableOnline)
.WithMessage("This plan must be purchased via sales contact.");
RuleFor(x => x.Months).InclusiveBetween(1, 12);
}
}
@@ -0,0 +1,14 @@
using FluentValidation;
using Meezi.API.Models.Menu;
namespace Meezi.API.Validators;
public class UpsertBranchMenuOverrideRequestValidator : AbstractValidator<UpsertBranchMenuOverrideRequest>
{
public UpsertBranchMenuOverrideRequestValidator()
{
RuleFor(x => x.PriceOverride)
.GreaterThanOrEqualTo(0)
.When(x => x.PriceOverride.HasValue);
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
using Meezi.API.Models.Public;
namespace Meezi.API.Validators;
public class CoffeeAdvisorRequestValidator : AbstractValidator<CoffeeAdvisorRequest>
{
public CoffeeAdvisorRequestValidator()
{
RuleFor(x => x.Purpose).NotEmpty().MinimumLength(3).MaximumLength(500);
RuleFor(x => x.CafeSlug).MaximumLength(80).When(x => !string.IsNullOrEmpty(x.CafeSlug));
}
}
@@ -0,0 +1,32 @@
using FluentValidation;
using Meezi.API.Models.Branches;
using Meezi.Core.Utilities;
namespace Meezi.API.Validators;
public class CreateBranchRequestValidator : AbstractValidator<CreateBranchRequest>
{
public CreateBranchRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.LoginPhone)
.NotEmpty()
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
.WithMessage("Invalid Iranian mobile number for branch login.");
RuleFor(x => x.ManagerName).MaximumLength(200).When(x => x.ManagerName is not null);
RuleFor(x => x.Address).MaximumLength(500).When(x => x.Address is not null);
RuleFor(x => x.City).MaximumLength(100).When(x => x.City is not null);
RuleFor(x => x.Phone).MaximumLength(20).When(x => x.Phone is not null);
}
}
public class PatchBranchRequestValidator : AbstractValidator<PatchBranchRequest>
{
public PatchBranchRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200).When(x => x.Name is not null);
RuleFor(x => x.Address).MaximumLength(500).When(x => x.Address is not null);
RuleFor(x => x.City).MaximumLength(100).When(x => x.City is not null);
RuleFor(x => x.Phone).MaximumLength(20).When(x => x.Phone is not null);
}
}
+68
View File
@@ -0,0 +1,68 @@
using FluentValidation;
using Meezi.API.Models.Crm;
using Meezi.Core.Utilities;
namespace Meezi.API.Validators;
public class CreateCustomerRequestValidator : AbstractValidator<CreateCustomerRequest>
{
public CreateCustomerRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Phone)
.NotEmpty()
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
.WithMessage("Invalid Iranian mobile number.");
RuleFor(x => x.NationalId)
.Matches(@"^\d{10}$")
.When(x => !string.IsNullOrWhiteSpace(x.NationalId))
.WithMessage("National ID must be 10 digits.");
RuleFor(x => x.Group).IsInEnum();
}
}
public class UpdateCustomerRequestValidator : AbstractValidator<UpdateCustomerRequest>
{
public UpdateCustomerRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(200)
.When(x => x.Name is not null);
RuleFor(x => x.Phone)
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p!)))
.When(x => !string.IsNullOrWhiteSpace(x.Phone))
.WithMessage("Invalid Iranian mobile number.");
RuleFor(x => x.NationalId)
.Matches(@"^\d{10}$")
.When(x => !string.IsNullOrWhiteSpace(x.NationalId))
.WithMessage("National ID must be 10 digits.");
RuleFor(x => x.Group)
.IsInEnum()
.When(x => x.Group.HasValue);
RuleFor(x => x.LoyaltyPoints)
.GreaterThanOrEqualTo(0)
.When(x => x.LoyaltyPoints.HasValue);
}
}
public class CreateCouponRequestValidator : AbstractValidator<CreateCouponRequest>
{
public CreateCouponRequestValidator()
{
RuleFor(x => x.Code).NotEmpty().MaximumLength(50);
RuleFor(x => x.Type).IsInEnum();
RuleFor(x => x.Value).GreaterThan(0);
}
}
public class SendSmsCampaignRequestValidator : AbstractValidator<SendSmsCampaignRequest>
{
public SendSmsCampaignRequestValidator()
{
RuleFor(x => x.Message).NotEmpty().MaximumLength(500);
RuleFor(x => x)
.Must(x => x.TargetGroup.HasValue || (x.Phones is { Count: > 0 }))
.WithMessage("Specify TargetGroup or at least one phone number.");
}
}
@@ -0,0 +1,15 @@
using FluentValidation;
using Meezi.API.Models.Expenses;
namespace Meezi.API.Validators;
public class CreateExpenseRequestValidator : AbstractValidator<CreateExpenseRequest>
{
public CreateExpenseRequestValidator()
{
RuleFor(x => x.BranchId).NotEmpty();
RuleFor(x => x.Amount).GreaterThan(0);
RuleFor(x => x.Note).MaximumLength(500).When(x => x.Note is not null);
RuleFor(x => x.ReceiptImageUrl).MaximumLength(500).When(x => x.ReceiptImageUrl is not null);
}
}
+33
View File
@@ -0,0 +1,33 @@
using FluentValidation;
using Meezi.API.Models.Hr;
using Meezi.Core.Enums;
namespace Meezi.API.Validators;
public class CreateLeaveRequestValidator : AbstractValidator<CreateLeaveRequest>
{
public CreateLeaveRequestValidator()
{
RuleFor(x => x.EndDate).GreaterThanOrEqualTo(x => x.StartDate);
}
}
public class ReviewLeaveRequestValidator : AbstractValidator<ReviewLeaveRequest>
{
public ReviewLeaveRequestValidator()
{
RuleFor(x => x.Status)
.Must(s => s is LeaveStatus.Approved or LeaveStatus.Rejected)
.WithMessage("Status must be Approved or Rejected.");
}
}
public class CreateSalaryRequestValidator : AbstractValidator<CreateSalaryRequest>
{
public CreateSalaryRequestValidator()
{
RuleFor(x => x.EmployeeId).NotEmpty();
RuleFor(x => x.MonthYear).Matches(@"^\d{4}-\d{2}$");
RuleFor(x => x.BaseSalary).GreaterThanOrEqualTo(0);
}
}
@@ -0,0 +1,22 @@
using FluentValidation;
using Meezi.API.Models.Kitchen;
namespace Meezi.API.Validators;
public class CreateKitchenStationRequestValidator : AbstractValidator<CreateKitchenStationRequest>
{
public CreateKitchenStationRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.PrinterPort).InclusiveBetween(1, 65535);
}
}
public class UpdateKitchenStationRequestValidator : AbstractValidator<UpdateKitchenStationRequest>
{
public UpdateKitchenStationRequestValidator()
{
RuleFor(x => x.Name).MaximumLength(100).When(x => x.Name is not null);
RuleFor(x => x.PrinterPort).InclusiveBetween(1, 65535).When(x => x.PrinterPort.HasValue);
}
}
@@ -0,0 +1,28 @@
using FluentValidation;
using Meezi.API.Models.Cafes;
namespace Meezi.API.Validators;
public class PatchCafeSettingsRequestValidator : AbstractValidator<PatchCafeSettingsRequest>
{
public PatchCafeSettingsRequestValidator()
{
RuleFor(x => x.Name).MaximumLength(200).When(x => x.Name is not null);
RuleFor(x => x.Phone).MaximumLength(20).When(x => x.Phone is not null);
RuleFor(x => x.Address).MaximumLength(500).When(x => x.Address is not null);
RuleFor(x => x.City).MaximumLength(100).When(x => x.City is not null);
RuleFor(x => x.Description).MaximumLength(2000).When(x => x.Description is not null);
RuleFor(x => x.LogoUrl).MaximumLength(500).When(x => x.LogoUrl is not null);
RuleFor(x => x.CoverImageUrl).MaximumLength(500).When(x => x.CoverImageUrl is not null);
RuleFor(x => x.SnappfoodVendorId).MaximumLength(100).When(x => x.SnappfoodVendorId is not null);
When(x => x.Theme is not null, () =>
{
RuleFor(x => x.Theme!.PaletteId).NotEmpty().MaximumLength(48);
RuleFor(x => x.Theme!.PanelStyle).NotEmpty().MaximumLength(24);
RuleFor(x => x.Theme!.MenuStyle).NotEmpty().MaximumLength(24);
RuleFor(x => x.Theme!.MenuTexture).NotEmpty().MaximumLength(24);
RuleFor(x => x.Theme!.Density).NotEmpty().MaximumLength(24);
RuleFor(x => x.Theme!.Radius).NotEmpty().MaximumLength(24);
});
}
}
+169
View File
@@ -0,0 +1,169 @@
using FluentValidation;
using Meezi.API.Models.Menu;
using Meezi.API.Models.Orders;
using Meezi.API.Models.Tables;
using Meezi.Core.Constants;
using Meezi.Core.Utilities;
namespace Meezi.API.Validators;
public class CreateMenuCategoryRequestValidator : AbstractValidator<CreateMenuCategoryRequest>
{
public CreateMenuCategoryRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null);
RuleFor(x => x.IconPresetId)
.Must(id => id is null || CategoryIconPresets.IsValidPreset(id))
.WithMessage("Invalid icon preset.")
.MaximumLength(48)
.When(x => x.IconPresetId is not null);
RuleFor(x => x.IconStyle)
.Must(s => s is null || CategoryIconPresets.IsValidStyle(s))
.WithMessage("Invalid icon style.")
.MaximumLength(16)
.When(x => x.IconStyle is not null);
RuleFor(x => x.ImageUrl).MaximumLength(500).When(x => x.ImageUrl is not null);
RuleFor(x => x)
.Must(x => string.IsNullOrWhiteSpace(x.IconPresetId) == string.IsNullOrWhiteSpace(x.IconStyle))
.WithMessage("Icon preset and style must be set together.");
}
}
public class CreateMenuItemRequestValidator : AbstractValidator<CreateMenuItemRequest>
{
public CreateMenuItemRequestValidator()
{
RuleFor(x => x.CategoryId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
}
}
public class CreateTableRequestValidator : AbstractValidator<CreateTableRequest>
{
public CreateTableRequestValidator()
{
RuleFor(x => x.Number).NotEmpty().MaximumLength(50);
RuleFor(x => x.Capacity).GreaterThan(0);
}
}
public class PatchTableRequestValidator : AbstractValidator<PatchTableRequest>
{
public PatchTableRequestValidator()
{
RuleFor(x => x.Number).NotEmpty().MaximumLength(50).When(x => x.Number is not null);
RuleFor(x => x.Capacity).GreaterThan(0).When(x => x.Capacity.HasValue);
}
}
public class CreateBranchTableRequestValidator : AbstractValidator<CreateBranchTableRequest>
{
public CreateBranchTableRequestValidator()
{
RuleFor(x => x.Number).NotEmpty().MaximumLength(50);
RuleFor(x => x.Capacity).GreaterThan(0);
}
}
public class PatchBranchTableRequestValidator : AbstractValidator<PatchBranchTableRequest>
{
public PatchBranchTableRequestValidator()
{
RuleFor(x => x.Number).NotEmpty().MaximumLength(50).When(x => x.Number is not null);
RuleFor(x => x.Capacity).GreaterThan(0).When(x => x.Capacity.HasValue);
}
}
public class CreateTableSectionRequestValidator : AbstractValidator<CreateTableSectionRequest>
{
public CreateTableSectionRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
}
}
public class PatchTableSectionRequestValidator : AbstractValidator<PatchTableSectionRequest>
{
public PatchTableSectionRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100).When(x => x.Name is not null);
}
}
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.OrderType).IsInEnum();
RuleFor(x => x.Items).NotEmpty();
RuleFor(x => x.GuestName).MaximumLength(200).When(x => x.GuestName is not null);
RuleFor(x => x.GuestPhone)
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p!)))
.When(x => !string.IsNullOrWhiteSpace(x.GuestPhone));
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.MenuItemId).NotEmpty();
item.RuleFor(i => i.Quantity).GreaterThan(0);
});
}
}
public class UpdateOrderStatusRequestValidator : AbstractValidator<UpdateOrderStatusRequest>
{
public UpdateOrderStatusRequestValidator()
{
RuleFor(x => x.Status).IsInEnum();
}
}
public class RecordPaymentsRequestValidator : AbstractValidator<RecordPaymentsRequest>
{
public RecordPaymentsRequestValidator()
{
RuleFor(x => x.Payments).NotEmpty();
RuleForEach(x => x.Payments).ChildRules(p =>
{
p.RuleFor(x => x.Method).IsInEnum();
p.RuleFor(x => x.Amount).GreaterThan(0);
});
RuleFor(x => x.LoyaltyPointsToRedeem)
.GreaterThanOrEqualTo(0)
.When(x => x.LoyaltyPointsToRedeem.HasValue);
}
}
public class AppendOrderItemsRequestValidator : AbstractValidator<AppendOrderItemsRequest>
{
public AppendOrderItemsRequestValidator()
{
RuleFor(x => x.Items).NotEmpty();
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.MenuItemId).NotEmpty();
item.RuleFor(i => i.Quantity).GreaterThan(0);
});
}
}
public class UpdateOrderSessionRequestValidator : AbstractValidator<UpdateOrderSessionRequest>
{
public UpdateOrderSessionRequestValidator()
{
RuleFor(x => x.GuestName).MaximumLength(200).When(x => x.GuestName is not null);
RuleFor(x => x.GuestPhone)
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p!)))
.When(x => !string.IsNullOrWhiteSpace(x.GuestPhone));
}
}
public class SetTableCleaningRequestValidator : AbstractValidator<SetTableCleaningRequest>
{
public SetTableCleaningRequestValidator() { }
}
@@ -0,0 +1,44 @@
using FluentValidation;
using Meezi.API.Models.Printing;
namespace Meezi.API.Validators;
public class PatchBranchPrintSettingsRequestValidator : AbstractValidator<PatchBranchPrintSettingsRequest>
{
public PatchBranchPrintSettingsRequestValidator()
{
RuleFor(x => x.ReceiptPrinterPort)
.InclusiveBetween(1, 65535)
.When(x => x.ReceiptPrinterPort.HasValue);
RuleFor(x => x.KitchenPrinterPort)
.InclusiveBetween(1, 65535)
.When(x => x.KitchenPrinterPort.HasValue);
RuleFor(x => x.PaperWidthMm)
.Must(w => w is null or 58 or 80)
.WithMessage("Paper width must be 58 or 80 mm.");
RuleFor(x => x.ReceiptHeader).MaximumLength(500).When(x => x.ReceiptHeader is not null);
RuleFor(x => x.ReceiptFooter).MaximumLength(500).When(x => x.ReceiptFooter is not null);
RuleFor(x => x.WifiPassword).MaximumLength(100).When(x => x.WifiPassword is not null);
RuleFor(x => x.PosDevicePort)
.InclusiveBetween(1, 65535)
.When(x => x.PosDevicePort.HasValue);
}
}
public class PosPaymentRequestValidator : AbstractValidator<PosPaymentRequest>
{
public PosPaymentRequestValidator()
{
RuleFor(x => x.OrderId).NotEmpty().MaximumLength(64);
RuleFor(x => x.Amount).GreaterThan(0);
}
}
public class TestPrintRequestValidator : AbstractValidator<TestPrintRequest>
{
public TestPrintRequestValidator()
{
RuleFor(x => x.PrinterIp).NotEmpty().MaximumLength(45);
RuleFor(x => x.Port).InclusiveBetween(1, 65535);
}
}
@@ -0,0 +1,42 @@
using FluentValidation;
using Meezi.API.Models.Public;
namespace Meezi.API.Validators;
public class GuestCreateOrderRequestValidator : AbstractValidator<GuestCreateOrderRequest>
{
public GuestCreateOrderRequestValidator()
{
RuleFor(x => x.Items).NotEmpty();
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.MenuItemId).NotEmpty();
item.RuleFor(i => i.Quantity).GreaterThan(0);
});
}
}
public class PlaceGuestOrderRequestValidator : AbstractValidator<PlaceGuestOrderRequest>
{
public PlaceGuestOrderRequestValidator()
{
RuleFor(x => x.TableId).NotEmpty();
RuleFor(x => x.Items).NotEmpty();
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.MenuItemId).NotEmpty();
item.RuleFor(i => i.Quantity).GreaterThan(0);
});
}
}
public class CreateReservationRequestValidator : AbstractValidator<CreateReservationRequest>
{
public CreateReservationRequestValidator()
{
RuleFor(x => x.GuestName).NotEmpty().MaximumLength(200);
RuleFor(x => x.GuestPhone).NotEmpty().Matches(@"^09\d{9}$");
RuleFor(x => x.PartySize).InclusiveBetween(1, 20);
RuleFor(x => x.Date).Must(d => d >= DateOnly.FromDateTime(DateTime.UtcNow.Date));
}
}
@@ -0,0 +1,23 @@
using FluentValidation;
using Meezi.API.Models.Public;
namespace Meezi.API.Validators;
public class CreateCafeReviewRequestValidator : AbstractValidator<CreateCafeReviewRequest>
{
public CreateCafeReviewRequestValidator()
{
RuleFor(x => x.AuthorName).NotEmpty().MaximumLength(200);
RuleFor(x => x.Rating).InclusiveBetween(1, 5);
RuleFor(x => x.AuthorPhone).Matches(@"^09\d{9}$").When(x => !string.IsNullOrEmpty(x.AuthorPhone));
RuleFor(x => x.Comment).MaximumLength(2000);
}
}
public class ReplyCafeReviewRequestValidator : AbstractValidator<ReplyCafeReviewRequest>
{
public ReplyCafeReviewRequestValidator()
{
RuleFor(x => x.Reply).NotEmpty().MaximumLength(2000);
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
using Meezi.API.Models.Shifts;
namespace Meezi.API.Validators;
public class OpenShiftRequestValidator : AbstractValidator<OpenShiftRequest>
{
public OpenShiftRequestValidator()
{
RuleFor(x => x.OpeningCash).GreaterThanOrEqualTo(0);
}
}
public class CloseShiftRequestValidator : AbstractValidator<CloseShiftRequest>
{
public CloseShiftRequestValidator()
{
RuleFor(x => x.ClosingCash).GreaterThanOrEqualTo(0);
}
}