diff --git a/src/Meezi.API/Controllers/AuthController.cs b/src/Meezi.API/Controllers/AuthController.cs index 5d089a6..9d9dd00 100644 --- a/src/Meezi.API/Controllers/AuthController.cs +++ b/src/Meezi.API/Controllers/AuthController.cs @@ -38,6 +38,26 @@ public class AuthController : ControllerBase _verifyRegisterValidator = verifyRegisterValidator; } + [HttpPost("login")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task LoginWithPassword( + [FromBody] LoginWithPasswordRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) + return BadRequest(ValidationError("Username and password are required.")); + + var (success, data, code, message, choices) = await _authService.LoginWithPasswordAsync(request, cancellationToken); + + if (!success && code == "CHOOSE_CAFE") + return Ok(new ApiResponse(false, choices, new ApiError("CHOOSE_CAFE", "Please select a café to continue."))); + + if (!success) + return ErrorResult(code!, message!); + + return Ok(new ApiResponse(true, data)); + } + [HttpPost("send-otp")] [EnableRateLimiting("auth-otp")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -193,6 +213,9 @@ public class AuthController : ControllerBase return new ApiResponse(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)); } + private static ApiResponse ValidationError(string message) => + new(false, null, new ApiError("VALIDATION_ERROR", message)); + private IActionResult ErrorResult(string code, string message) => code switch { "RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests, diff --git a/src/Meezi.API/Controllers/HrController.cs b/src/Meezi.API/Controllers/HrController.cs index 0194606..8b16680 100644 --- a/src/Meezi.API/Controllers/HrController.cs +++ b/src/Meezi.API/Controllers/HrController.cs @@ -1,9 +1,12 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Meezi.API.Models.Hr; using Meezi.API.Services; using Meezi.Core.Enums; using Meezi.Core.Interfaces; +using Meezi.Core.Utilities; +using Meezi.Infrastructure.Data; using Meezi.Shared; namespace Meezi.API.Controllers; @@ -15,17 +18,20 @@ public class HrController : CafeApiControllerBase private readonly IValidator _leaveValidator; private readonly IValidator _reviewValidator; private readonly IValidator _salaryValidator; + private readonly AppDbContext _db; public HrController( IHrService hr, IValidator leaveValidator, IValidator reviewValidator, - IValidator salaryValidator) + IValidator salaryValidator, + AppDbContext db) { _hr = hr; _leaveValidator = leaveValidator; _reviewValidator = reviewValidator; _salaryValidator = salaryValidator; + _db = db; } [HttpGet("employees")] @@ -201,4 +207,66 @@ public class HrController : CafeApiControllerBase if (data is null) return NotFoundError(); return Ok(new ApiResponse(true, data)); } + + /// Set or update username/password credentials for an employee. Owner/Manager only. + [HttpPut("employees/{employeeId}/credentials")] + public async Task SetCredentials( + string cafeId, + string employeeId, + [FromBody] SetEmployeeCredentialsRequest request, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsureManager(tenant) is { } forbidden) return forbidden; + + var username = request.Username.Trim().ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(username)) + return BadRequest(new ApiResponse(false, null, new ApiError("VALIDATION_ERROR", "Username is required.", "Username"))); + + if (request.Password.Length < 8) + return BadRequest(new ApiResponse(false, null, new ApiError("VALIDATION_ERROR", "Password must be at least 8 characters.", "Password"))); + + var employee = await _db.Employees + .FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct); + + if (employee is null) return NotFoundError(); + + // Check username uniqueness within the cafe (excluding the employee itself) + var conflict = await _db.Employees + .AnyAsync(e => e.CafeId == cafeId && e.Id != employeeId && e.DeletedAt == null + && e.Username != null && e.Username.ToLower() == username, ct); + if (conflict) + return Conflict(new ApiResponse(false, null, new ApiError("USERNAME_TAKEN", "This username is already in use by another employee."))); + + employee.Username = username; + employee.PasswordHash = PasswordHasher.Hash(request.Password); + await _db.SaveChangesAsync(ct); + + return Ok(new ApiResponse(true, null)); + } + + /// Remove username/password credentials from an employee. Owner/Manager only. + [HttpDelete("employees/{employeeId}/credentials")] + public async Task RemoveCredentials( + string cafeId, + string employeeId, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsureManager(tenant) is { } forbidden) return forbidden; + + var employee = await _db.Employees + .FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct); + + if (employee is null) return NotFoundError(); + + employee.Username = null; + employee.PasswordHash = null; + await _db.SaveChangesAsync(ct); + + return Ok(new ApiResponse(true, null)); + } } diff --git a/src/Meezi.API/Models/Auth/AuthDtos.cs b/src/Meezi.API/Models/Auth/AuthDtos.cs index d34d8b5..08afe6f 100644 --- a/src/Meezi.API/Models/Auth/AuthDtos.cs +++ b/src/Meezi.API/Models/Auth/AuthDtos.cs @@ -2,6 +2,9 @@ namespace Meezi.API.Models.Auth; public record SendOtpRequest(string Phone); +/// Username + password login (alternative to OTP). Optional cafeId to scope to a specific café. +public record LoginWithPasswordRequest(string Username, string Password, string? CafeId = null); + public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null); public record RefreshTokenRequest(string RefreshToken); diff --git a/src/Meezi.API/Models/Hr/HrDtos.cs b/src/Meezi.API/Models/Hr/HrDtos.cs index 690e2e6..a24131d 100644 --- a/src/Meezi.API/Models/Hr/HrDtos.cs +++ b/src/Meezi.API/Models/Hr/HrDtos.cs @@ -59,3 +59,6 @@ public record CreateSalaryRequest( decimal Deductions); public record TodayShiftDto(ShiftType ShiftType, string Label); + +/// Set or update username/password credentials for an employee. +public record SetEmployeeCredentialsRequest(string Username, string Password); diff --git a/src/Meezi.API/Services/AuthService.cs b/src/Meezi.API/Services/AuthService.cs index e75df95..92034c4 100644 --- a/src/Meezi.API/Services/AuthService.cs +++ b/src/Meezi.API/Services/AuthService.cs @@ -403,6 +403,61 @@ public class AuthService : IAuthService return slug; } + public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync( + LoginWithPasswordRequest request, + CancellationToken cancellationToken = default) + { + var username = request.Username.Trim(); + + var candidates = await _db.Employees + .Include(e => e.Cafe) + .Where(e => e.Username == username + && e.PasswordHash != null + && e.DeletedAt == null + && e.Cafe.DeletedAt == null) + .ToListAsync(cancellationToken); + + if (candidates.Count == 0) + return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null); + + // Constant-time verification (check all matches to avoid username enumeration) + var matched = candidates.Where(e => PasswordHasher.Verify(request.Password, e.PasswordHash!)).ToList(); + + if (matched.Count == 0) + return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null); + + // Scope to a specific café if requested + if (!string.IsNullOrWhiteSpace(request.CafeId)) + { + matched = matched.Where(e => e.CafeId == request.CafeId).ToList(); + if (matched.Count == 0) + return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null); + } + + // Multiple cafés — ask frontend to pick one + if (matched.Count > 1) + { + var choices = new CafeChoicesResponse( + matched + .Where(e => e.Cafe is not null) + .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) + .ToList()); + return (false, null, "CHOOSE_CAFE", null, choices); + } + + var employee = matched[0]; + if (employee.Cafe is null) + return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null); + + var membershipDtos = matched + .Where(e => e.Cafe is not null) + .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) + .ToList(); + + var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, null, cancellationToken); + return (true, tokens, null, null, null); + } + private async Task IssueTokensAsync( Core.Entities.Employee employee, Core.Entities.Cafe cafe, diff --git a/src/Meezi.API/Services/IAuthService.cs b/src/Meezi.API/Services/IAuthService.cs index d1cfca6..e350e2d 100644 --- a/src/Meezi.API/Services/IAuthService.cs +++ b/src/Meezi.API/Services/IAuthService.cs @@ -16,6 +16,10 @@ public interface IAuthService VerifyOtpRequest request, CancellationToken cancellationToken = default); + Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync( + LoginWithPasswordRequest request, + CancellationToken cancellationToken = default); + Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync( string employeeId, string targetCafeId, CancellationToken cancellationToken = default); diff --git a/src/Meezi.Admin.API/Controllers/AdminAuthController.cs b/src/Meezi.Admin.API/Controllers/AdminAuthController.cs index e07ab89..ca6b362 100644 --- a/src/Meezi.Admin.API/Controllers/AdminAuthController.cs +++ b/src/Meezi.Admin.API/Controllers/AdminAuthController.cs @@ -1,8 +1,11 @@ using FluentValidation; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Meezi.Admin.API.Models; using Meezi.Admin.API.Services; using Meezi.Shared; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; namespace Meezi.Admin.API.Controllers; @@ -55,6 +58,39 @@ public class AdminAuthController : ControllerBase return Ok(new ApiResponse(true, data)); } + [HttpPost("login")] + public async Task LoginWithPassword( + [FromBody] LoginWithPasswordRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) + return BadRequest(ValidationError("Username and password are required.")); + + var (success, data, code, message) = await _auth.LoginWithPasswordAsync(request, cancellationToken); + if (!success) + return ErrorResult(code!, message!); + + return Ok(new ApiResponse(true, data)); + } + + [HttpPut("password")] + [Authorize] + public async Task ChangePassword( + [FromBody] ChangePasswordRequest request, + CancellationToken cancellationToken) + { + var adminId = User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(adminId)) + return Unauthorized(); + + var (success, code, message) = await _auth.ChangePasswordAsync(adminId, request, cancellationToken); + if (!success) + return ErrorResult(code!, message!); + + return Ok(new ApiResponse(true, null)); + } + [HttpPost("refresh")] public async Task Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) { @@ -75,6 +111,9 @@ public class AdminAuthController : ControllerBase return new ApiResponse(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName)); } + private static ApiResponse ValidationError(string message) => + new(false, null, new ApiError("VALIDATION_ERROR", message)); + private IActionResult ErrorResult(string code, string message) => code switch { diff --git a/src/Meezi.Admin.API/Models/AuthDtos.cs b/src/Meezi.Admin.API/Models/AuthDtos.cs index 2e5d034..0077590 100644 --- a/src/Meezi.Admin.API/Models/AuthDtos.cs +++ b/src/Meezi.Admin.API/Models/AuthDtos.cs @@ -8,6 +8,10 @@ public record VerifyOtpRequest(string Phone, string Code); public record RefreshTokenRequest(string RefreshToken); +public record LoginWithPasswordRequest(string Username, string Password); + +public record ChangePasswordRequest(string CurrentPassword, string NewPassword); + public record AuthTokenResponse( string AccessToken, string RefreshToken, diff --git a/src/Meezi.Admin.API/Services/AdminAuthService.cs b/src/Meezi.Admin.API/Services/AdminAuthService.cs index 7095ae2..b1f60ed 100644 --- a/src/Meezi.Admin.API/Services/AdminAuthService.cs +++ b/src/Meezi.Admin.API/Services/AdminAuthService.cs @@ -19,6 +19,15 @@ public interface IAdminAuthService VerifyOtpRequest request, CancellationToken cancellationToken = default); + Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync( + LoginWithPasswordRequest request, + CancellationToken cancellationToken = default); + + Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync( + string adminId, + ChangePasswordRequest request, + CancellationToken cancellationToken = default); + Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync( RefreshTokenRequest request, CancellationToken cancellationToken = default); @@ -141,6 +150,49 @@ public class AdminAuthService : IAdminAuthService return (true, tokens, null, null); } + public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync( + LoginWithPasswordRequest request, + CancellationToken cancellationToken = default) + { + var username = request.Username.Trim(); + var admin = await _db.SystemAdmins + .FirstOrDefaultAsync(a => a.Username == username && a.IsActive && a.DeletedAt == null, cancellationToken); + + if (admin is null || string.IsNullOrWhiteSpace(admin.PasswordHash)) + return (false, null, "INVALID_CREDENTIALS", "Invalid username or password."); + + if (!PasswordHasher.Verify(request.Password, admin.PasswordHash)) + return (false, null, "INVALID_CREDENTIALS", "Invalid username or password."); + + var tokens = await IssueTokensAsync(admin, cancellationToken); + return (true, tokens, null, null); + } + + public async Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync( + string adminId, + ChangePasswordRequest request, + CancellationToken cancellationToken = default) + { + var admin = await _db.SystemAdmins + .FirstOrDefaultAsync(a => a.Id == adminId && a.IsActive && a.DeletedAt == null, cancellationToken); + if (admin is null) + return (false, "NOT_FOUND", "Admin not found."); + + // If a password is already set, require the current one + if (!string.IsNullOrWhiteSpace(admin.PasswordHash)) + { + if (!PasswordHasher.Verify(request.CurrentPassword, admin.PasswordHash)) + return (false, "INVALID_CREDENTIALS", "Current password is incorrect."); + } + + if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8) + return (false, "VALIDATION_ERROR", "New password must be at least 8 characters."); + + admin.PasswordHash = PasswordHasher.Hash(request.NewPassword); + await _db.SaveChangesAsync(cancellationToken); + return (true, null, null); + } + private async Task IssueTokensAsync( Core.Entities.SystemAdmin admin, CancellationToken cancellationToken) diff --git a/src/Meezi.Core/Entities/Employee.cs b/src/Meezi.Core/Entities/Employee.cs index 135c9d9..48724f9 100644 --- a/src/Meezi.Core/Entities/Employee.cs +++ b/src/Meezi.Core/Entities/Employee.cs @@ -12,6 +12,12 @@ public class Employee : TenantEntity public decimal BaseSalary { get; set; } public string? PinCode { get; set; } + /// Optional username for password-based dashboard/POS login (set by cafe admin). + public string? Username { get; set; } + + /// PBKDF2/SHA-256 hash. Null means password login is not enabled for this employee. + public string? PasswordHash { get; set; } + public Cafe Cafe { get; set; } = null!; public Branch? Branch { get; set; } public ICollection Orders { get; set; } = []; diff --git a/src/Meezi.Core/Entities/SystemAdmin.cs b/src/Meezi.Core/Entities/SystemAdmin.cs index a8c1f69..716014e 100644 --- a/src/Meezi.Core/Entities/SystemAdmin.cs +++ b/src/Meezi.Core/Entities/SystemAdmin.cs @@ -5,4 +5,10 @@ public class SystemAdmin : BaseEntity public string Name { get; set; } = string.Empty; public string Phone { get; set; } = string.Empty; public bool IsActive { get; set; } = true; + + /// Optional username for password-based login (alternative to OTP). + public string? Username { get; set; } + + /// PBKDF2/SHA-256 hash. Null means password login is not enabled. + public string? PasswordHash { get; set; } } diff --git a/src/Meezi.Core/Utilities/PasswordHasher.cs b/src/Meezi.Core/Utilities/PasswordHasher.cs new file mode 100644 index 0000000..51339aa --- /dev/null +++ b/src/Meezi.Core/Utilities/PasswordHasher.cs @@ -0,0 +1,40 @@ +using System.Security.Cryptography; + +namespace Meezi.Core.Utilities; + +/// +/// PBKDF2/SHA-256 password hashing with no external dependencies. +/// Format stored: "{iterations}.{salt_b64}.{hash_b64}" +/// +public static class PasswordHasher +{ + private const int SaltSize = 16; // 128-bit salt + private const int HashSize = 32; // 256-bit hash + private const int Iterations = 100_000; // NIST-recommended minimum + private static readonly HashAlgorithmName Algo = HashAlgorithmName.SHA256; + + public static string Hash(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, Algo, HashSize); + return $"{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; + } + + public static bool Verify(string password, string storedHash) + { + var parts = storedHash.Split('.'); + if (parts.Length != 3) return false; + if (!int.TryParse(parts[0], out var iterations)) return false; + + byte[] salt, expectedHash; + try + { + salt = Convert.FromBase64String(parts[1]); + expectedHash = Convert.FromBase64String(parts[2]); + } + catch (FormatException) { return false; } + + var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, Algo, expectedHash.Length); + return CryptographicOperations.FixedTimeEquals(actual, expectedHash); + } +} diff --git a/src/Meezi.Infrastructure/Data/Migrations/20260531154653_AddPasswordLogin.Designer.cs b/src/Meezi.Infrastructure/Data/Migrations/20260531154653_AddPasswordLogin.Designer.cs new file mode 100644 index 0000000..ad4dbe7 --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260531154653_AddPasswordLogin.Designer.cs @@ -0,0 +1,3299 @@ +// +using System; +using Meezi.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Meezi.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260531154653_AddPasswordLogin")] + partial class AddPasswordLogin + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Meezi.Core.Entities.Attendance", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ClockIn") + .HasColumnType("timestamp with time zone"); + + b.Property("ClockOut") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId", "Date") + .IsUnique(); + + b.ToTable("Attendances"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.AuditLog", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("ActorId") + .HasColumnType("text"); + + b.Property("ActorName") + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorRole") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DetailsJson") + .HasColumnType("text"); + + b.Property("EntityId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EntityType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "BranchId"); + + b.HasIndex("CafeId", "Category"); + + b.HasIndex("CafeId", "CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Branch", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccentColor") + .HasColumnType("text"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AutoCutEnabled") + .HasColumnType("boolean"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("KitchenPrinterIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("KitchenPrinterPort") + .HasColumnType("integer"); + + b.Property("LogoUrl") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PaperWidthMm") + .HasColumnType("integer"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PosDeviceIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("PosDevicePort") + .HasColumnType("integer"); + + b.Property("ReceiptFooter") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptHeader") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptPrinterIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ReceiptPrinterPort") + .HasColumnType("integer"); + + b.Property("ScheduledPermanentDeleteAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TaxRate") + .HasColumnType("numeric"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WelcomeText") + .HasColumnType("text"); + + b.Property("WifiPassword") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "IsActive"); + + b.ToTable("Branches"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.BranchMenuItemOverride", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("MenuItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PriceOverride") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SortOrderOverride") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByUserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("MenuItemId"); + + b.HasIndex("BranchId", "MenuItemId") + .IsUnique(); + + b.ToTable("BranchMenuItemOverrides"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Cafe", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AllowBranchTaxOverride") + .HasColumnType("boolean"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultTaxRate") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DigikalaVendorId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DiscoverBadgesJson") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("DiscoverProfileJson") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("GalleryJson") + .HasColumnType("text"); + + b.Property("InstagramHandle") + .HasColumnType("text"); + + b.Property("IsSuspended") + .HasColumnType("boolean"); + + b.Property("IsVerified") + .HasColumnType("boolean"); + + b.Property("LogoUrl") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NameAr") + .HasColumnType("text"); + + b.Property("NameEn") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("PlanExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlanTier") + .HasColumnType("integer"); + + b.Property("PreferredLanguage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SnappfoodVendorId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Tap30VendorId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ThemeJson") + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("WebsiteUrl") + .HasColumnType("text"); + + b.Property("WorkingHoursJson") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Cafes"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeFeatureOverride", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FeatureKey") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "FeatureKey") + .IsUnique(); + + b.ToTable("CafeFeatureOverrides"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeNotification", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Body") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TableNumber") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "IsRead", "CreatedAt"); + + b.ToTable("CafeNotifications"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeReview", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AuthorPhone") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsHidden") + .HasColumnType("boolean"); + + b.Property("OwnerRepliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OwnerReply") + .HasColumnType("text"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "CreatedAt"); + + b.ToTable("CafeReviews"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeReviewPhoto", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("ReviewId"); + + b.ToTable("CafeReviewPhotos"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CashTransaction", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .HasColumnType("integer"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("ShiftId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("ShiftId"); + + b.HasIndex("CafeId", "BranchId"); + + b.ToTable("CashTransactions"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.ConsumerAccount", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("ConsumerAccounts"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Coupon", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("MaxDiscount") + .HasColumnType("numeric"); + + b.Property("MinOrderAmount") + .HasColumnType("numeric"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TargetGroup") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UsageLimit") + .HasColumnType("integer"); + + b.Property("UsedCount") + .HasColumnType("integer"); + + b.Property("Value") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "Code") + .IsUnique(); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Customer", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BirthDateJalali") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Group") + .HasColumnType("integer"); + + b.Property("LoyaltyPoints") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NationalId") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferredBy") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "Phone"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.DailyReport", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AvgOrderValue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CardRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CashRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreditRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NetIncome") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TopProducts") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TotalExpenses") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TotalOrders") + .HasColumnType("integer"); + + b.Property("TotalRevenue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TotalVoids") + .HasColumnType("integer"); + + b.Property("VoidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("CafeId", "BranchId", "Date") + .IsUnique(); + + b.ToTable("DailyReports"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.DeliveryCommissionRate", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Platform") + .HasColumnType("integer"); + + b.Property("RatePercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CafeId", "Platform") + .IsUnique(); + + b.ToTable("DeliveryCommissionRates"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.DemoRequest", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AdminNotes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("BranchCount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("BusinessName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ContactedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Source") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasDefaultValue("website"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("DemoRequests"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Employee", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BaseSalary") + .HasColumnType("numeric"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NationalId") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("PinCode") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("CafeId", "Phone") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("CafeId", "BranchId"); + + b.HasIndex("EmployeeId", "BranchId") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("EmployeeBranchRoles"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BaseSalary") + .HasColumnType("numeric"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deductions") + .HasColumnType("numeric"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsPaid") + .HasColumnType("boolean"); + + b.Property("MonthYear") + .IsRequired() + .HasColumnType("text"); + + b.Property("NetSalary") + .HasColumnType("numeric"); + + b.Property("OvertimePay") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId", "MonthYear") + .IsUnique(); + + b.ToTable("EmployeeSalaries"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeSchedule", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShiftType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId", "DayOfWeek") + .IsUnique(); + + b.ToTable("EmployeeSchedules", (string)null); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Expense", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptImageUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ShiftId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("ShiftId"); + + b.HasIndex("CafeId", "BranchId", "CreatedAt"); + + b.ToTable("Expenses"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LowStockWarningPercent") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParLevel") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("QuantityOnHand") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("ReorderLevel") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UnitCost") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.ToTable("Ingredients"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PrinterIp") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("PrinterPort") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("CafeId", "SortOrder"); + + b.ToTable("KitchenStations"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.LeaveRequest", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("Reason") + .HasColumnType("text"); + + b.Property("ReviewedBy") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("LeaveRequests"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountPercent") + .HasColumnType("numeric"); + + b.Property("Icon") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IconPresetId") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("IconStyle") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ImageUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("KitchenStationId") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameAr") + .HasColumnType("text"); + + b.Property("NameEn") + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TaxId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("KitchenStationId"); + + b.HasIndex("TaxId"); + + b.ToTable("MenuCategories"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuItem", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CategoryId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DiscountPercent") + .HasColumnType("numeric"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("Model3dUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameAr") + .HasColumnType("text"); + + b.Property("NameEn") + .HasColumnType("text"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("VideoUrl") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("CategoryId"); + + b.ToTable("MenuItems"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IngredientId") + .IsRequired() + .HasColumnType("text"); + + b.Property("MenuItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("QuantityPerUnit") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("MenuItemId"); + + b.HasIndex("CafeId", "MenuItemId", "IngredientId") + .IsUnique(); + + b.ToTable("MenuItemIngredients"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Order", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CancelReason") + .HasColumnType("text"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CancelledByEmployeeId") + .HasColumnType("text"); + + b.Property("CouponId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeliveryMetaJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("DeliveryPlatform") + .HasColumnType("integer"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("DisplayNumber") + .HasColumnType("integer"); + + b.Property("EmployeeId") + .HasColumnType("text"); + + b.Property("ExternalOrderId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GuestName") + .HasColumnType("text"); + + b.Property("GuestPhone") + .HasColumnType("text"); + + b.Property("GuestTrackingToken") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OrderType") + .HasColumnType("integer"); + + b.Property("PlatformCommission") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ReservationId") + .HasColumnType("text"); + + b.Property("SnappfoodOrderId") + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StatusUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Subtotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("TableId") + .HasColumnType("text"); + + b.Property("TaxTotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Total") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("CouponId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("GuestTrackingToken"); + + b.HasIndex("ReservationId"); + + b.HasIndex("TableId"); + + b.HasIndex("CafeId", "DisplayNumber") + .IsUnique(); + + b.HasIndex("CafeId", "DeliveryPlatform", "ExternalOrderId"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.OrderItem", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsVoided") + .HasColumnType("boolean"); + + b.Property("MenuItemId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OrderId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("VoidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VoidedByUserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MenuItemId"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Payment", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .HasColumnType("integer"); + + b.Property("OrderId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reference") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.PlatformFeature", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayNameEn") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DisplayNameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsEnabledGlobally") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("ModuleGroup") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformFeatures"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.PlatformPlanDefinition", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayNameEn") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DisplayNameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FeaturesJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsBillableOnline") + .HasColumnType("boolean"); + + b.Property("LimitsJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("MonthlyPriceToman") + .HasPrecision(18) + .HasColumnType("numeric(18,0)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Tier") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Tier") + .IsUnique(); + + b.ToTable("PlatformPlanDefinitions"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.PlatformSetting", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DescriptionFa") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformSettings"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.PushDevice", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConsumerAccountId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("City"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("PushDevices"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerLabel") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserId") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("OrderId") + .HasColumnType("text"); + + b.Property("ServiceDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BranchId"); + + b.HasIndex("OrderId"); + + b.HasIndex("CafeId", "BranchId", "ServiceDate", "Number") + .IsUnique(); + + b.ToTable("QueueTickets"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Shift", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ClosedByUserId") + .HasColumnType("text"); + + b.Property("ClosingCash") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Discrepancy") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExpectedCash") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OpenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OpenedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("OpeningCash") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("ClosedByUserId"); + + b.HasIndex("OpenedByUserId"); + + b.HasIndex("BranchId", "Status"); + + b.ToTable("RegisterShifts", (string)null); + }); + + modelBuilder.Entity("Meezi.Core.Entities.StockMovement", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Delta") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("ExpenseId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IngredientId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrderId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalCostToman") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("IngredientId"); + + b.HasIndex("CafeId", "OrderId"); + + b.ToTable("StockMovements"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SubscriptionPayment", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AmountRials") + .HasColumnType("bigint"); + + b.Property("AmountToman") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Authority") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Months") + .HasColumnType("integer"); + + b.Property("PlanTier") + .HasColumnType("integer"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("RefId") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Authority"); + + b.HasIndex("CafeId"); + + b.ToTable("SubscriptionPayments"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SupportTicket", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AssignedAdminId") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByEmployeeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByEmployeeId"); + + b.HasIndex("CafeId", "Status", "UpdatedAt"); + + b.ToTable("SupportTickets"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SupportTicketMessage", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SenderId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SenderKind") + .HasColumnType("integer"); + + b.Property("TicketId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("SupportTicketMessages"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SystemAdmin", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("SystemAdmins"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Table", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Floor") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsCleaning") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("QrCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SectionId") + .HasColumnType("text"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("VideoUrl") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("QrCode") + .IsUnique(); + + b.HasIndex("SectionId"); + + b.HasIndex("BranchId", "SectionId", "SortOrder"); + + b.ToTable("Tables"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.TableReservation", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("text"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GuestName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GuestPhone") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PartySize") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TableId") + .HasColumnType("text"); + + b.Property("Time") + .HasColumnType("time without time zone"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("TableId"); + + b.HasIndex("CafeId", "Date", "Time"); + + b.ToTable("TableReservations"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.TableSection", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("BranchId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("BranchId", "Name"); + + b.ToTable("TableSections"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Tax", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCompound") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rate") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.ToTable("Taxes"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebhookLog", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CafeId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ExternalOrderId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MeeziOrderId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Platform") + .HasColumnType("integer"); + + b.Property("Processed") + .HasColumnType("boolean"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RawBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("SignatureHeader") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SignatureValid") + .HasColumnType("boolean"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.HasIndex("Platform", "CreatedAt"); + + b.ToTable("WebhookLogs"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebsiteBlogPost", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Author") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CategoryEn") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CategoryFa") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ContentEn") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentFa") + .IsRequired() + .HasColumnType("text"); + + b.Property("CoverImage") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExcerptEn") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ExcerptFa") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TagsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasDefaultValue("[]"); + + b.Property("TitleEn") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("TitleFa") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("ViewCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("IsPublished", "PublishedAt"); + + b.ToTable("WebsiteBlogPosts"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebsiteComment", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AuthorEmail") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsApproved") + .HasColumnType("boolean"); + + b.Property("PostSlug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("PostSlug", "IsApproved", "CreatedAt"); + + b.ToTable("WebsiteComments"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Attendance", b => + { + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("Attendances") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.AuditLog", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", null) + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Branch", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Branches") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.BranchMenuItemOverride", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.MenuItem", "MenuItem") + .WithMany("BranchOverrides") + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("MenuItem"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeFeatureOverride", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", null) + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeReview", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Reviews") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeReviewPhoto", b => + { + b.HasOne("Meezi.Core.Entities.CafeReview", "Review") + .WithMany("Photos") + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Review"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CashTransaction", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Shift", "Shift") + .WithMany("Transactions") + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Shift"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Coupon", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Coupons") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Customer", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Customers") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.DailyReport", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.DeliveryCommissionRate", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Employee", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany("Staff") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Employees") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany("StaffRoles") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("BranchRoles") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b => + { + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("Salaries") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.EmployeeSchedule", b => + { + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("Schedules") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Expense", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Shift", "Shift") + .WithMany() + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Branch"); + + b.Navigation("Shift"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Ingredients") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.LeaveRequest", b => + { + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("LeaveRequests") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("MenuCategories") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.KitchenStation", "KitchenStation") + .WithMany("Categories") + .HasForeignKey("KitchenStationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Tax", "Tax") + .WithMany("MenuCategories") + .HasForeignKey("TaxId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Cafe"); + + b.Navigation("KitchenStation"); + + b.Navigation("Tax"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuItem", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("MenuItems") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.MenuCategory", "Category") + .WithMany("MenuItems") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b => + { + b.HasOne("Meezi.Core.Entities.Ingredient", "Ingredient") + .WithMany("MenuItemRecipes") + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.MenuItem", "MenuItem") + .WithMany("RecipeIngredients") + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ingredient"); + + b.Navigation("MenuItem"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Order", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany("Orders") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Orders") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Coupon", "Coupon") + .WithMany("Orders") + .HasForeignKey("CouponId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Customer", "Customer") + .WithMany("Orders") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Employee", "Employee") + .WithMany("Orders") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.TableReservation", "Reservation") + .WithMany() + .HasForeignKey("ReservationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Table", "Table") + .WithMany("Orders") + .HasForeignKey("TableId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + + b.Navigation("Coupon"); + + b.Navigation("Customer"); + + b.Navigation("Employee"); + + b.Navigation("Reservation"); + + b.Navigation("Table"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.OrderItem", b => + { + b.HasOne("Meezi.Core.Entities.MenuItem", "MenuItem") + .WithMany("OrderItems") + .HasForeignKey("MenuItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Order", "Order") + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MenuItem"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Payment", b => + { + b.HasOne("Meezi.Core.Entities.Order", "Order") + .WithMany("Payments") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Shift", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Employee", "ClosedBy") + .WithMany() + .HasForeignKey("ClosedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Employee", "OpenedBy") + .WithMany() + .HasForeignKey("OpenedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + + b.Navigation("ClosedBy"); + + b.Navigation("OpenedBy"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.StockMovement", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Ingredient", "Ingredient") + .WithMany("Movements") + .HasForeignKey("IngredientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + + b.Navigation("Ingredient"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SubscriptionPayment", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("SubscriptionPayments") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SupportTicket", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Employee", "CreatedByEmployee") + .WithMany() + .HasForeignKey("CreatedByEmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cafe"); + + b.Navigation("CreatedByEmployee"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SupportTicketMessage", b => + { + b.HasOne("Meezi.Core.Entities.SupportTicket", "Ticket") + .WithMany("Messages") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ticket"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Table", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany("Tables") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Tables") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.TableSection", "Section") + .WithMany("Tables") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Branch"); + + b.Navigation("Cafe"); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.TableReservation", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Meezi.Core.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Meezi.Core.Entities.Table", "Table") + .WithMany() + .HasForeignKey("TableId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Cafe"); + + b.Navigation("Customer"); + + b.Navigation("Table"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.TableSection", b => + { + b.HasOne("Meezi.Core.Entities.Branch", "Branch") + .WithMany("Sections") + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Tax", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany("Taxes") + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebhookLog", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId"); + + b.Navigation("Cafe"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebsiteComment", b => + { + b.HasOne("Meezi.Core.Entities.WebsiteBlogPost", "Post") + .WithMany("Comments") + .HasForeignKey("PostSlug") + .HasPrincipalKey("Slug") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Branch", b => + { + b.Navigation("Orders"); + + b.Navigation("Sections"); + + b.Navigation("Staff"); + + b.Navigation("StaffRoles"); + + b.Navigation("Tables"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Cafe", b => + { + b.Navigation("Branches"); + + b.Navigation("Coupons"); + + b.Navigation("Customers"); + + b.Navigation("Employees"); + + b.Navigation("Ingredients"); + + b.Navigation("MenuCategories"); + + b.Navigation("MenuItems"); + + b.Navigation("Orders"); + + b.Navigation("Reviews"); + + b.Navigation("SubscriptionPayments"); + + b.Navigation("Tables"); + + b.Navigation("Taxes"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.CafeReview", b => + { + b.Navigation("Photos"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Coupon", b => + { + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Customer", b => + { + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Employee", b => + { + b.Navigation("Attendances"); + + b.Navigation("BranchRoles"); + + b.Navigation("LeaveRequests"); + + b.Navigation("Orders"); + + b.Navigation("Salaries"); + + b.Navigation("Schedules"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b => + { + b.Navigation("MenuItemRecipes"); + + b.Navigation("Movements"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b => + { + b.Navigation("MenuItems"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.MenuItem", b => + { + b.Navigation("BranchOverrides"); + + b.Navigation("OrderItems"); + + b.Navigation("RecipeIngredients"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Order", b => + { + b.Navigation("Items"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Shift", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.SupportTicket", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Table", b => + { + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.TableSection", b => + { + b.Navigation("Tables"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.Tax", b => + { + b.Navigation("MenuCategories"); + }); + + modelBuilder.Entity("Meezi.Core.Entities.WebsiteBlogPost", b => + { + b.Navigation("Comments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Meezi.Infrastructure/Data/Migrations/20260531154653_AddPasswordLogin.cs b/src/Meezi.Infrastructure/Data/Migrations/20260531154653_AddPasswordLogin.cs new file mode 100644 index 0000000..8fa6f03 --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260531154653_AddPasswordLogin.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Meezi.Infrastructure.Data.Migrations +{ + /// + public partial class AddPasswordLogin : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PasswordHash", + table: "SystemAdmins", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Username", + table: "SystemAdmins", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "PasswordHash", + table: "Employees", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Username", + table: "Employees", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PasswordHash", + table: "SystemAdmins"); + + migrationBuilder.DropColumn( + name: "Username", + table: "SystemAdmins"); + + migrationBuilder.DropColumn( + name: "PasswordHash", + table: "Employees"); + + migrationBuilder.DropColumn( + name: "Username", + table: "Employees"); + } + } +} diff --git a/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 0e89955..353560a 100644 --- a/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -929,6 +929,9 @@ namespace Meezi.Infrastructure.Data.Migrations b.Property("NationalId") .HasColumnType("text"); + b.Property("PasswordHash") + .HasColumnType("text"); + b.Property("Phone") .IsRequired() .HasColumnType("text"); @@ -939,6 +942,9 @@ namespace Meezi.Infrastructure.Data.Migrations b.Property("Role") .HasColumnType("integer"); + b.Property("Username") + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("BranchId"); @@ -2119,11 +2125,17 @@ namespace Meezi.Infrastructure.Data.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("PasswordHash") + .HasColumnType("text"); + b.Property("Phone") .IsRequired() .HasMaxLength(20) .HasColumnType("character varying(20)"); + b.Property("Username") + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("Phone") diff --git a/src/Meezi.Infrastructure/Data/PlatformDataSeeder.cs b/src/Meezi.Infrastructure/Data/PlatformDataSeeder.cs index a48f254..8fddee4 100644 --- a/src/Meezi.Infrastructure/Data/PlatformDataSeeder.cs +++ b/src/Meezi.Infrastructure/Data/PlatformDataSeeder.cs @@ -3,7 +3,9 @@ using Meezi.Core.Constants; using Meezi.Core.Entities; using Meezi.Core.Enums; using Meezi.Core.Platform; +using Meezi.Core.Utilities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -17,18 +19,25 @@ public static class PlatformDataSeeder public static async Task SeedAsync(IServiceProvider services) { var env = services.GetRequiredService(); - if (!env.IsDevelopment()) - return; - var logger = services.GetRequiredService().CreateLogger("PlatformDataSeeder"); await using var scope = services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); + var config = scope.ServiceProvider.GetRequiredService(); - await EnsureCatalogUpgradesAsync(db, logger); + // Production-safe: ensure the platform owner's system-admin account exists + // on every boot (ALL environments) so the admin panel is reachable on a + // fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone". + await EnsureOwnerAdminAsync(db, config, logger); if (!env.IsDevelopment()) + { + // Production: also ensure integration settings (Kavenegar enabled/template, + // etc.) exist so the admin Integrations page is populated. Idempotent. + await EnsureIntegrationSettingsAsync(db, logger); return; + } + await EnsureCatalogUpgradesAsync(db, logger); await SeedSystemAdminAsync(db, logger); await SeedPlansAsync(db, logger); await SeedFeaturesAsync(db, logger); @@ -36,6 +45,49 @@ public static class PlatformDataSeeder await EnsureIntegrationSettingsAsync(db, logger); } + /// + /// Ensures the platform owner's system-admin account exists in EVERY environment + /// (including production), so the admin panel is reachable on a fresh deploy. + /// The phone is configurable via "Seed:SystemAdminPhone" (env Seed__SystemAdminPhone) + /// and defaults to the platform owner's number. Idempotent — never duplicates. + /// + private static async Task EnsureOwnerAdminAsync(AppDbContext db, IConfiguration config, ILogger logger) + { + const string DefaultOwnerPhone = "09190345606"; + var configured = config["Seed:SystemAdminPhone"]; + var phone = PhoneNormalizer.Normalize( + string.IsNullOrWhiteSpace(configured) ? DefaultOwnerPhone : configured); + + if (!PhoneNormalizer.IsValidIranMobile(phone)) + { + logger.LogWarning("Owner system-admin seed skipped — invalid phone '{Phone}'", phone); + return; + } + + if (await db.SystemAdmins.AnyAsync(a => a.Phone == phone)) + return; + + db.SystemAdmins.Add(new SystemAdmin + { + Id = "sysadmin_owner", + Name = "مدیر سامانه", + Phone = phone, + IsActive = true + }); + + try + { + await db.SaveChangesAsync(); + logger.LogInformation("Seeded owner system admin with phone {Phone}", phone); + } + catch (DbUpdateException) + { + // api + admin-api boot concurrently against the same DB; another instance + // already inserted this admin. Safe to ignore. + logger.LogInformation("Owner system admin already seeded by another instance"); + } + } + /// Idempotent plan/feature upgrades for all environments (including production). public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services) { @@ -126,7 +178,7 @@ public static class PlatformDataSeeder S("payment.vandar.enabled", "false", "payment", "فعال وندار"), S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"), S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"), - S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"), + S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"), S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"), S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"), S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"), @@ -296,7 +348,7 @@ public static class PlatformDataSeeder S("payment.vandar.enabled", "false", "payment", "فعال وندار"), S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"), S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"), - S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"), + S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"), S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"), S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"), S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"), diff --git a/web/admin/messages/ar.json b/web/admin/messages/ar.json index 49e3f72..802e109 100644 --- a/web/admin/messages/ar.json +++ b/web/admin/messages/ar.json @@ -1093,7 +1093,14 @@ "otp": "رمز التحقق", "login": "دخول", "error": "فشل تسجيل الدخول", - "devHint": "في التطوير يُطبع الرمز في سجل Admin API." + "devHint": "في التطوير يُطبع الرمز في سجل Admin API.", + "tabOtp": "رمز مؤقت", + "tabPassword": "كلمة المرور", + "username": "اسم المستخدم", + "usernamePlaceholder": "اسم المستخدم", + "password": "كلمة المرور", + "passwordPlaceholder": "كلمة المرور", + "invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة." }, "dashboard": { "title": "نظرة عامة", diff --git a/web/admin/messages/en.json b/web/admin/messages/en.json index 6be8420..eea4a46 100644 --- a/web/admin/messages/en.json +++ b/web/admin/messages/en.json @@ -1086,7 +1086,14 @@ "otp": "Verification code", "login": "Sign in", "error": "Login failed", - "devHint": "In development the OTP is logged by Admin API (DEV admin OTP)." + "devHint": "In development the OTP is logged by Admin API (DEV admin OTP).", + "tabOtp": "One-time code", + "tabPassword": "Password", + "username": "Username", + "usernamePlaceholder": "Username", + "password": "Password", + "passwordPlaceholder": "Password", + "invalidCredentials": "Incorrect username or password." }, "dashboard": { "title": "Platform overview", diff --git a/web/admin/messages/fa.json b/web/admin/messages/fa.json index 9932818..0d3b472 100644 --- a/web/admin/messages/fa.json +++ b/web/admin/messages/fa.json @@ -1086,7 +1086,14 @@ "otp": "کد تأیید", "login": "ورود", "error": "خطا در ورود", - "devHint": "در حالت توسعه کد در لاگ Admin API چاپ می‌شود (DEV admin OTP)." + "devHint": "در حالت توسعه کد در لاگ Admin API چاپ می‌شود (DEV admin OTP).", + "tabOtp": "کد یکبارمصرف", + "tabPassword": "رمز عبور", + "username": "نام کاربری", + "usernamePlaceholder": "نام کاربری", + "password": "رمز عبور", + "passwordPlaceholder": "رمز عبور", + "invalidCredentials": "نام کاربری یا رمز عبور اشتباه است." }, "dashboard": { "title": "خلاصه سامانه", diff --git a/web/admin/src/app/[locale]/admin/login/page.tsx b/web/admin/src/app/[locale]/admin/login/page.tsx index 35933db..3f60047 100644 --- a/web/admin/src/app/[locale]/admin/login/page.tsx +++ b/web/admin/src/app/[locale]/admin/login/page.tsx @@ -13,14 +13,25 @@ import { Input } from "@/components/ui/input"; import { LabeledField } from "@/components/ui/labeled-field"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +type LoginTab = "otp" | "password"; + export default function AdminLoginPage() { const t = useTranslations("admin.auth"); const tAuth = useTranslations("auth"); const router = useRouter(); const setAuth = useAdminAuthStore((s) => s.setAuth); + + const [tab, setTab] = useState("otp"); + + // OTP state const [phone, setPhone] = useState("09120000001"); const [code, setCode] = useState(""); - const [step, setStep] = useState<"phone" | "otp">("phone"); + const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone"); + + // Password state + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -34,6 +45,8 @@ export default function AdminLoginPage() { case "INVALID_OTP": case "VALIDATION_ERROR": return tAuth("invalidOtp"); + case "INVALID_TOKEN": + return t("invalidCredentials"); default: return err.message; } @@ -46,7 +59,7 @@ export default function AdminLoginPage() { setError(null); try { await adminPost("/api/admin/auth/send-otp", { phone }); - setStep("otp"); + setOtpStep("otp"); setCode(""); } catch (e) { setError(authErrorMessage(e)); @@ -55,7 +68,7 @@ export default function AdminLoginPage() { } }; - const verify = async () => { + const verifyOtp = async () => { const normalized = normalizeOtpInput(code); if (normalized.length !== 6) { setError(tAuth("invalidOtp")); @@ -77,6 +90,34 @@ export default function AdminLoginPage() { } }; + const loginWithPassword = async () => { + if (!username.trim() || !password) { + setError(t("invalidCredentials")); + return; + } + setLoading(true); + setError(null); + try { + const data = await adminPost("/api/admin/auth/login", { + username: username.trim(), + password, + }); + setAuth(data); + router.push("/admin"); + } catch (e) { + setError(authErrorMessage(e)); + } finally { + setLoading(false); + } + }; + + const switchTab = (next: LoginTab) => { + setTab(next); + setError(null); + setOtpStep("phone"); + setCode(""); + }; + return (
@@ -87,8 +128,36 @@ export default function AdminLoginPage() {

{t("devHint")}

) : null} - - {step === "phone" ? ( + + {/* Tab switcher */} +
+ + +
+ + + {/* ───── OTP tab ───── */} + {tab === "otp" && otpStep === "phone" && (
{ @@ -111,12 +180,14 @@ export default function AdminLoginPage() { {loading ? "..." : t("sendOtp")}
- ) : ( + )} + + {tab === "otp" && otpStep === "otp" && (
{ e.preventDefault(); - if (!loading) void verify(); + if (!loading) void verifyOtp(); }} > @@ -142,7 +213,7 @@ export default function AdminLoginPage() { className="w-full" disabled={loading} onClick={() => { - setStep("phone"); + setOtpStep("phone"); setCode(""); setError(null); }} @@ -151,6 +222,46 @@ export default function AdminLoginPage() { )} + + {/* ───── Password tab ───── */} + {tab === "password" && ( +
{ + e.preventDefault(); + if (!loading) void loginWithPassword(); + }} + > + + setUsername(e.target.value)} + placeholder={t("usernamePlaceholder")} + dir="ltr" + className="text-start" + autoComplete="username" + autoFocus + /> + + + setPassword(e.target.value)} + placeholder={t("passwordPlaceholder")} + dir="ltr" + className="text-start" + autoComplete="current-password" + /> + + +
+ )} + {error ?

{error}

: null}
diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 0b3bb61..485eac6 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -45,7 +45,14 @@ "chooseCafe": "اختر المقهى", "chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.", "createNewCafe": "إنشاء مقهى جديد", - "createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟" + "createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟", + "tabOtp": "رمز مؤقت", + "tabPassword": "كلمة المرور", + "username": "اسم المستخدم", + "usernamePlaceholder": "اسم المستخدم", + "password": "كلمة المرور", + "passwordPlaceholder": "كلمة المرور", + "invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة." }, "roles": { "owner": "المالك", @@ -386,7 +393,8 @@ "attendance": "الحضور", "leave": "الإجازة", "payroll": "الرواتب", - "access": "صلاحيات الفروع" + "access": "صلاحيات الفروع", + "credentials": "بيانات الدخول" }, "myAttendance": "حضوري", "clockIn": "تسجيل دخول", @@ -396,7 +404,22 @@ "paid": "مدفوع", "markPaid": "تسجيل الدفع", "employeeCount": "الموظفون", - "monthYear": "شهر الرواتب" + "monthYear": "شهر الرواتب", + "credentials": { + "title": "بيانات دخول الموظفين", + "subtitle": "حدد اسم مستخدم وكلمة مرور لكل موظف حتى يتمكن من تسجيل الدخول دون رمز OTP.", + "selectEmployee": "اختر موظفاً أولاً", + "username": "اسم المستخدم", + "usernamePlaceholder": "مثال: ali_barista", + "password": "كلمة المرور (8 أحرف على الأقل)", + "passwordPlaceholder": "كلمة مرور جديدة", + "set": "حفظ بيانات الدخول", + "remove": "حذف بيانات الدخول", + "removeConfirm": "هل أنت متأكد؟ لن يتمكن الموظف من تسجيل الدخول بكلمة مرور بعد الآن.", + "saved": "تم حفظ بيانات الدخول.", + "removed": "تم حذف بيانات الدخول.", + "usernameTaken": "اسم المستخدم هذا مستخدم بالفعل." + } }, "reviews": { "title": "تقييمات العملاء", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 4dc6e41..022f5f2 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -56,7 +56,14 @@ "chooseCafe": "Choose a café", "chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.", "createNewCafe": "Create a new café", - "createNewCafeHint": "Want to start your own café with this number?" + "createNewCafeHint": "Want to start your own café with this number?", + "tabOtp": "One-time code", + "tabPassword": "Password", + "username": "Username", + "usernamePlaceholder": "Username", + "password": "Password", + "passwordPlaceholder": "Password", + "invalidCredentials": "Incorrect username or password." }, "roles": { "owner": "Owner", @@ -405,7 +412,8 @@ "attendance": "Attendance", "leave": "Leave", "payroll": "Payroll", - "access": "Branch access" + "access": "Branch access", + "credentials": "Login credentials" }, "myAttendance": "My attendance", "clockIn": "Clock in", @@ -415,7 +423,22 @@ "paid": "Paid", "markPaid": "Mark paid", "employeeCount": "Employees", - "monthYear": "Payroll month" + "monthYear": "Payroll month", + "credentials": { + "title": "Employee login credentials", + "subtitle": "Set a username and password for each employee so they can sign in without an OTP.", + "selectEmployee": "Select an employee first", + "username": "Username", + "usernamePlaceholder": "e.g. ali_barista", + "password": "Password (min 8 characters)", + "passwordPlaceholder": "New password", + "set": "Save credentials", + "remove": "Remove credentials", + "removeConfirm": "Are you sure? The employee will no longer be able to sign in with a password.", + "saved": "Credentials saved.", + "removed": "Credentials removed.", + "usernameTaken": "This username is already taken." + } }, "reviews": { "title": "Customer reviews", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index 808c502..fada9d1 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -56,7 +56,14 @@ "chooseCafe": "انتخاب کافه", "chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.", "createNewCafe": "ایجاد کافه جدید", - "createNewCafeHint": "می‌خواهید کافه خودتان را با همین شماره راه‌اندازی کنید؟" + "createNewCafeHint": "می‌خواهید کافه خودتان را با همین شماره راه‌اندازی کنید؟", + "tabOtp": "کد یکبارمصرف", + "tabPassword": "رمز عبور", + "username": "نام کاربری", + "usernamePlaceholder": "نام کاربری", + "password": "رمز عبور", + "passwordPlaceholder": "رمز عبور", + "invalidCredentials": "نام کاربری یا رمز عبور اشتباه است." }, "roles": { "owner": "مالک", @@ -405,7 +412,8 @@ "attendance": "حضور و غیاب", "leave": "مرخصی", "payroll": "حقوق", - "access": "دسترسی شعب" + "access": "دسترسی شعب", + "credentials": "رمز ورود" }, "myAttendance": "حضور من", "clockIn": "ورود", @@ -415,7 +423,22 @@ "paid": "پرداخت شده", "markPaid": "ثبت پرداخت", "employeeCount": "تعداد کارمندان", - "monthYear": "ماه حقوق" + "monthYear": "ماه حقوق", + "credentials": { + "title": "مدیریت رمز ورود کارمندان", + "subtitle": "برای هر کارمند می‌توانید نام کاربری و رمز عبور تعریف کنید تا بدون نیاز به کد OTP وارد شوند.", + "selectEmployee": "ابتدا یک کارمند انتخاب کنید", + "username": "نام کاربری", + "usernamePlaceholder": "مثال: ali_barista", + "password": "رمز عبور (حداقل ۸ کاراکتر)", + "passwordPlaceholder": "رمز عبور جدید", + "set": "ذخیره رمز ورود", + "remove": "حذف رمز ورود", + "removeConfirm": "آیا مطمئنید؟ کارمند دیگر نمی‌تواند با رمز عبور وارد شود.", + "saved": "رمز ورود ذخیره شد.", + "removed": "رمز ورود حذف شد.", + "usernameTaken": "این نام کاربری قبلاً استفاده شده است." + } }, "reviews": { "title": "نظرات مشتریان", diff --git a/web/dashboard/src/app/[locale]/login/page.tsx b/web/dashboard/src/app/[locale]/login/page.tsx index 17d79e5..3d1add8 100644 --- a/web/dashboard/src/app/[locale]/login/page.tsx +++ b/web/dashboard/src/app/[locale]/login/page.tsx @@ -12,14 +12,24 @@ import { LabeledField } from "@/components/ui/labeled-field"; import { OtpInput } from "@/components/ui/otp-input"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +type LoginTab = "otp" | "password"; + export default function LoginPage() { const t = useTranslations("auth"); const router = useRouter(); const setAuth = useAuthStore((s) => s.setAuth); + const [tab, setTab] = useState("otp"); + + // OTP state const [phone, setPhone] = useState("09121234567"); const [code, setCode] = useState(""); - const [step, setStep] = useState<"phone" | "otp">("phone"); + const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone"); + + // Password state + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -32,6 +42,9 @@ export default function LoginPage() { return t("smsFailed"); case "INVALID_OTP": return t("invalidOtp"); + case "INVALID_TOKEN": + case "NOT_FOUND": + return tab === "password" ? t("invalidCredentials") : t("notFound"); default: return err.message; } @@ -44,7 +57,7 @@ export default function LoginPage() { setError(null); try { await apiPost("/api/auth/send-otp", { phone }); - setStep("otp"); + setOtpStep("otp"); } catch (e) { if (e instanceof ApiClientError && e.code === "NOT_FOUND") { // No account → take them to register with phone pre-filled @@ -74,6 +87,34 @@ export default function LoginPage() { } }; + const loginWithPassword = async () => { + if (!username.trim() || !password) { + setError(t("invalidCredentials")); + return; + } + setLoading(true); + setError(null); + try { + const data = await apiPost("/api/auth/login", { + username: username.trim(), + password, + }); + setAuth(data); + router.push("/pos"); + } catch (e) { + setError(authErrorMessage(e)); + } finally { + setLoading(false); + } + }; + + const switchTab = (next: LoginTab) => { + setTab(next); + setError(null); + setOtpStep("phone"); + setCode(""); + }; + return (
@@ -81,8 +122,36 @@ export default function LoginPage() { {t("title")}

{t("subtitle")}

- - {step === "phone" ? ( + + {/* Tab switcher */} +
+ + +
+ + + {/* ───── OTP tab ───── */} + {tab === "otp" && otpStep === "phone" && (
{ @@ -105,7 +174,9 @@ export default function LoginPage() { {loading ? "..." : t("sendOtp")}
- ) : ( + )} + + {tab === "otp" && otpStep === "otp" && (
{ @@ -128,12 +199,60 @@ export default function LoginPage() { type="button" variant="ghost" className="w-full" - onClick={() => setStep("phone")} + onClick={() => { + setOtpStep("phone"); + setCode(""); + setError(null); + }} > {t("resend")}
)} + + {/* ───── Password tab ───── */} + {tab === "password" && ( +
{ + e.preventDefault(); + if (!loading) void loginWithPassword(); + }} + > + + setUsername(e.target.value)} + placeholder={t("usernamePlaceholder")} + dir="ltr" + className="text-start" + autoComplete="username" + autoFocus + /> + + + setPassword(e.target.value)} + placeholder={t("passwordPlaceholder")} + dir="ltr" + className="text-start" + autoComplete="current-password" + /> + + +
+ )} + {error && (

{error}

)} diff --git a/web/dashboard/src/components/hr/employee-credentials-panel.tsx b/web/dashboard/src/components/hr/employee-credentials-panel.tsx new file mode 100644 index 0000000..6f49a8b --- /dev/null +++ b/web/dashboard/src/components/hr/employee-credentials-panel.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { apiPut, apiDelete, ApiClientError } from "@/lib/api/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { LabeledField } from "@/components/ui/labeled-field"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface Employee { + id: string; + name: string; + phone: string; + role: string; +} + +interface Props { + cafeId: string; + employees: Employee[]; +} + +export function EmployeeCredentialsPanel({ cafeId, employees }: Props) { + const t = useTranslations("hr.credentials"); + + const [selectedId, setSelectedId] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [feedback, setFeedback] = useState<{ ok: boolean; msg: string } | null>(null); + + const setMutation = useMutation({ + mutationFn: () => + apiPut(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`, { + username, + password, + }), + onSuccess: () => { + setFeedback({ ok: true, msg: t("saved") }); + setPassword(""); + }, + onError: (err) => { + if (err instanceof ApiClientError && err.code === "USERNAME_TAKEN") { + setFeedback({ ok: false, msg: t("usernameTaken") }); + } else { + setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) }); + } + }, + }); + + const removeMutation = useMutation({ + mutationFn: () => + apiDelete(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`), + onSuccess: () => { + setFeedback({ ok: true, msg: t("removed") }); + setUsername(""); + setPassword(""); + }, + onError: (err) => { + setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) }); + }, + }); + + const handleRemove = () => { + if (!window.confirm(t("removeConfirm"))) return; + setFeedback(null); + removeMutation.mutate(); + }; + + const isPending = setMutation.isPending || removeMutation.isPending; + + return ( +
+
+

{t("subtitle")}

+
+ + {/* Employee selector */} +
+ {employees.map((emp) => ( + + ))} +
+ + {/* Form */} + {selectedId && ( + + + + {employees.find((e) => e.id === selectedId)?.name} + + + + + setUsername(e.target.value)} + placeholder={t("usernamePlaceholder")} + dir="ltr" + className="text-start" + autoComplete="off" + /> + + + setPassword(e.target.value)} + placeholder={t("passwordPlaceholder")} + dir="ltr" + className="text-start" + autoComplete="new-password" + /> + + + {feedback && ( +

+ {feedback.msg} +

+ )} + +
+ + +
+
+
+ )} + + {!selectedId && ( +

{t("selectEmployee")}

+ )} +
+ ); +} diff --git a/web/dashboard/src/components/hr/hr-screen.tsx b/web/dashboard/src/components/hr/hr-screen.tsx index 16f6da4..56f6e47 100644 --- a/web/dashboard/src/components/hr/hr-screen.tsx +++ b/web/dashboard/src/components/hr/hr-screen.tsx @@ -12,6 +12,7 @@ import { LabeledField } from "@/components/ui/labeled-field"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { BranchAccessPanel } from "@/components/hr/branch-access-panel"; +import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel"; interface Employee { id: string; @@ -47,7 +48,7 @@ interface Salary { isPaid: boolean; } -type Tab = "attendance" | "leave" | "payroll" | "access"; +type Tab = "attendance" | "leave" | "payroll" | "access" | "credentials"; export function HrScreen() { const t = useTranslations("hr"); @@ -122,8 +123,8 @@ export function HrScreen() {

{t("title")}

- {((["attendance", "leave", "payroll", "access"] as Tab[]).filter( - (key) => key !== "access" || canManageAccess + {((["attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter( + (key) => (key !== "access" && key !== "credentials") || canManageAccess )).map((key) => (
); } diff --git a/web/dashboard/src/components/layout/trial-countdown-banner.tsx b/web/dashboard/src/components/layout/trial-countdown-banner.tsx index 6e3c622..8602c29 100644 --- a/web/dashboard/src/components/layout/trial-countdown-banner.tsx +++ b/web/dashboard/src/components/layout/trial-countdown-banner.tsx @@ -5,8 +5,8 @@ import { useLocale } from "next-intl"; import { useRouter } from "@/i18n/routing"; import { Clock, X, Zap } from "lucide-react"; -// 14 Khordad 1405 = June 4, 2026 (Tehran UTC+3:30) -const DEADLINE = new Date("2026-06-04T00:00:00+03:30"); +// 1 Tir 1405 = June 22, 2026 (Tehran IRDT UTC+4:30) +const DEADLINE = new Date("2026-06-22T00:00:00+04:30"); const STORAGE_KEY = "meezi_trial_banner_v1"; interface TimeLeft { @@ -78,11 +78,11 @@ export function TrialCountdownBanner() { const textFa = expired ? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید." - : "دوره آزمایشی رایگان تا ۱۴ خرداد ۱۴۰۵"; + : "دوره آزمایشی رایگان تا ۱ تیر ۱۴۰۵"; const textEn = expired ? "Your Meezi trial has ended. Choose a plan to continue." - : "Free trial ends 14 Khordad 1405 (Jun 4)"; + : "Free trial ends 1 Tir 1405 (Jun 22)"; const Digit = ({ value, label }: { value: number; label: string }) => (