diff --git a/src/Meezi.API/Controllers/MediaController.cs b/src/Meezi.API/Controllers/MediaController.cs
index ae3f26d..ec709c0 100644
--- a/src/Meezi.API/Controllers/MediaController.cs
+++ b/src/Meezi.API/Controllers/MediaController.cs
@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
+using Meezi.Infrastructure.Data;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
@@ -81,6 +83,33 @@ public class MediaController : CafeApiControllerBase
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
+ /// Media library for this café — previously uploaded files so the UI can
+ /// reuse one instead of re-uploading. Deduplication means each distinct file appears once.
+ [HttpGet]
+ public async Task ListMedia(
+ string cafeId,
+ ITenantContext tenant,
+ [FromServices] AppDbContext db,
+ CancellationToken cancellationToken,
+ [FromQuery] string? kind = null,
+ [FromQuery] int limit = 60)
+ {
+ if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
+
+ var query = db.MediaAssets.AsNoTracking().Where(m => m.CafeId == cafeId);
+ if (!string.IsNullOrWhiteSpace(kind))
+ query = query.Where(m => m.Kind == kind);
+
+ var items = await query
+ .OrderByDescending(m => m.CreatedAt)
+ .Take(Math.Clamp(limit, 1, 200))
+ .Select(m => new MediaAssetDto(
+ m.Id, m.Url, m.Kind, m.ContentType, m.SizeBytes, m.OriginalFileName, m.CreatedAt))
+ .ToListAsync(cancellationToken);
+
+ return Ok(new ApiResponse>(true, items));
+ }
+
private async Task Upload(
string cafeId,
IFormFile file,
@@ -103,3 +132,12 @@ public class MediaController : CafeApiControllerBase
}
public record UploadResultDto(string Url);
+
+public record MediaAssetDto(
+ string Id,
+ string Url,
+ string Kind,
+ string ContentType,
+ long SizeBytes,
+ string? OriginalFileName,
+ DateTime CreatedAt);
diff --git a/src/Meezi.API/Services/MediaStorageService.cs b/src/Meezi.API/Services/MediaStorageService.cs
index 9b475cf..bd44917 100644
--- a/src/Meezi.API/Services/MediaStorageService.cs
+++ b/src/Meezi.API/Services/MediaStorageService.cs
@@ -1,3 +1,8 @@
+using System.Security.Cryptography;
+using Microsoft.EntityFrameworkCore;
+using Meezi.Core.Entities;
+using Meezi.Infrastructure.Data;
+
namespace Meezi.API.Services;
public interface IMediaStorageService
@@ -37,11 +42,16 @@ public class MediaStorageService : IMediaStorageService
private readonly IWebHostEnvironment _env;
private readonly ILogger _logger;
+ private readonly IServiceScopeFactory _scopeFactory;
- public MediaStorageService(IWebHostEnvironment env, ILogger logger)
+ public MediaStorageService(
+ IWebHostEnvironment env,
+ ILogger logger,
+ IServiceScopeFactory scopeFactory)
{
_env = env;
_logger = logger;
+ _scopeFactory = scopeFactory;
}
public Task SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
@@ -100,16 +110,29 @@ public class MediaStorageService : IMediaStorageService
|| Model3dMime.Contains(file.ContentType);
if (!isGlb) return null;
+ await using var buffer = new MemoryStream();
+ await file.CopyToAsync(buffer, cancellationToken);
+ var bytes = buffer.ToArray();
+ var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+
+ var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
+ if (existing is not null)
+ {
+ _logger.LogInformation("Dedup hit for 3D model (cafe {CafeId}); reusing existing file", cafeId);
+ return existing;
+ }
+
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
Directory.CreateDirectory(dir);
var savedName = $"menu_3d_{Guid.NewGuid():N}.glb";
var path = Path.Combine(dir, savedName);
- await using var stream = File.Create(path);
- await file.CopyToAsync(stream, cancellationToken);
+ await File.WriteAllBytesAsync(path, bytes, cancellationToken);
+ var url = $"/uploads/{cafeId}/{savedName}";
+ await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, "menu_3d", file.FileName, cancellationToken);
_logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId);
- return $"/uploads/{cafeId}/{savedName}";
+ return url;
}
private async Task SaveAsync(
@@ -123,6 +146,20 @@ public class MediaStorageService : IMediaStorageService
if (file.Length == 0 || file.Length > maxBytes) return null;
if (!allowedMime.Contains(file.ContentType)) return null;
+ // Buffer once so we can hash the content and (if new) write it.
+ await using var buffer = new MemoryStream();
+ await file.CopyToAsync(buffer, cancellationToken);
+ var bytes = buffer.ToArray();
+ var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+
+ // Dedup: an identical file already stored for this scope is reused as-is.
+ var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
+ if (existing is not null)
+ {
+ _logger.LogInformation("Dedup hit for {Prefix} (cafe {CafeId}); reusing existing file", prefix, cafeId);
+ return existing;
+ }
+
var ext = file.ContentType.ToLowerInvariant() switch
{
"image/png" => ".png",
@@ -138,10 +175,61 @@ public class MediaStorageService : IMediaStorageService
var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}";
var path = Path.Combine(dir, fileName);
- await using var stream = File.Create(path);
- await file.CopyToAsync(stream, cancellationToken);
+ await File.WriteAllBytesAsync(path, bytes, cancellationToken);
+ var url = $"/uploads/{cafeId}/{fileName}";
+ await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, prefix, file.FileName, cancellationToken);
_logger.LogInformation("Saved {Prefix} media for cafe {CafeId}", prefix, cafeId);
- return $"/uploads/{cafeId}/{fileName}";
+ return url;
+ }
+
+ // ─── Deduplication helpers ────────────────────────────────────────────────
+ // MediaStorageService is a singleton; resolve a scoped DbContext per call.
+
+ private async Task FindExistingByHashAsync(string? cafeId, string hash, CancellationToken ct)
+ {
+ try
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return await db.MediaAssets.AsNoTracking()
+ .Where(m => m.CafeId == cafeId && m.ContentHash == hash)
+ .Select(m => m.Url)
+ .FirstOrDefaultAsync(ct);
+ }
+ catch (Exception ex)
+ {
+ // Never let a dedup-lookup failure block an upload.
+ _logger.LogWarning(ex, "Media dedup lookup failed; proceeding with a fresh upload");
+ return null;
+ }
+ }
+
+ private async Task RecordAsync(
+ string? cafeId, string hash, long size, string contentType,
+ string url, string kind, string? originalName, CancellationToken ct)
+ {
+ try
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.MediaAssets.Add(new MediaAsset
+ {
+ CafeId = cafeId,
+ ContentHash = hash,
+ SizeBytes = size,
+ ContentType = contentType,
+ Url = url,
+ Kind = kind,
+ OriginalFileName = originalName,
+ });
+ await db.SaveChangesAsync(ct);
+ }
+ catch (Exception ex)
+ {
+ // The file is already written; a missing dedup record only means a
+ // future identical upload won't be de-duplicated. Don't fail the upload.
+ _logger.LogWarning(ex, "Failed to record media asset for cafe {CafeId}", cafeId);
+ }
}
}
diff --git a/src/Meezi.Core/Entities/MediaAsset.cs b/src/Meezi.Core/Entities/MediaAsset.cs
new file mode 100644
index 0000000..de99ec2
--- /dev/null
+++ b/src/Meezi.Core/Entities/MediaAsset.cs
@@ -0,0 +1,27 @@
+namespace Meezi.Core.Entities;
+
+///
+/// A stored upload, recorded so identical files can be de-duplicated and reused
+/// instead of written to disk again. Files with the same content hash within a
+/// scope (café, or platform when is null) share one stored file.
+///
+public class MediaAsset : BaseEntity
+{
+ /// Owning café, or null for platform-level (admin) uploads.
+ public string? CafeId { get; set; }
+
+ /// SHA-256 of the file content, lowercase hex.
+ public string ContentHash { get; set; } = string.Empty;
+
+ public long SizeBytes { get; set; }
+
+ public string ContentType { get; set; } = string.Empty;
+
+ /// Public URL/path the file is served from (e.g. /uploads/{cafeId}/{name}).
+ public string Url { get; set; } = string.Empty;
+
+ /// Logical kind/prefix: menu_img, logo, cover, gallery, review, menu_3d, blog, ...
+ public string Kind { get; set; } = string.Empty;
+
+ public string? OriginalFileName { get; set; }
+}
diff --git a/src/Meezi.Infrastructure/Data/AppDbContext.cs b/src/Meezi.Infrastructure/Data/AppDbContext.cs
index cae71bc..7058c58 100644
--- a/src/Meezi.Infrastructure/Data/AppDbContext.cs
+++ b/src/Meezi.Infrastructure/Data/AppDbContext.cs
@@ -85,6 +85,9 @@ public class AppDbContext : DbContext
// Idempotency keys for safe retry of offline-replayed writes.
public DbSet IdempotencyRecords => Set();
+ // Uploaded files, recorded for content-hash de-duplication and a media library.
+ public DbSet MediaAssets => Set();
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -113,6 +116,20 @@ public class AppDbContext : DbContext
e.HasQueryFilter(x => x.DeletedAt == null);
});
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+ // Dedup lookups: same content within a scope (café or platform).
+ e.HasIndex(x => new { x.CafeId, x.ContentHash });
+ e.Property(x => x.CafeId).HasMaxLength(64);
+ e.Property(x => x.ContentHash).HasMaxLength(64).IsRequired();
+ e.Property(x => x.ContentType).HasMaxLength(100).IsRequired();
+ e.Property(x => x.Url).HasMaxLength(500).IsRequired();
+ e.Property(x => x.Kind).HasMaxLength(40).IsRequired();
+ e.Property(x => x.OriginalFileName).HasMaxLength(260);
+ e.HasQueryFilter(x => x.DeletedAt == null);
+ });
+
modelBuilder.Entity(e =>
{
e.HasKey(x => x.Id);
diff --git a/src/Meezi.Infrastructure/Data/Migrations/20260602184236_AddMediaAssets.Designer.cs b/src/Meezi.Infrastructure/Data/Migrations/20260602184236_AddMediaAssets.Designer.cs
new file mode 100644
index 0000000..a3d0083
--- /dev/null
+++ b/src/Meezi.Infrastructure/Data/Migrations/20260602184236_AddMediaAssets.Designer.cs
@@ -0,0 +1,3413 @@
+//
+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("20260602184236_AddMediaAssets")]
+ partial class AddMediaAssets
+ {
+ ///
+ 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("Latitude")
+ .HasColumnType("double precision");
+
+ b.Property("LogoUrl")
+ .HasColumnType("text");
+
+ b.Property("Longitude")
+ .HasColumnType("double precision");
+
+ 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("ShowOnKoja")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ 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