diff --git a/.env.example b/.env.example index b690b70..d76473f 100644 --- a/.env.example +++ b/.env.example @@ -81,5 +81,5 @@ KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F433346 SNAPPFOOD_WEBHOOK_SECRET=change-me-snappfood-secret # ── Docker image overrides (if direct MCR pull fails) ──────────────────────── -# DOTNET_SDK_IMAGE=171.22.25.73:5002/dotnet/sdk:10.0 -# DOTNET_ASPNET_IMAGE=171.22.25.73:5002/dotnet/aspnet:10.0 +# DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0 +# DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0 diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index c2e7822..07789f8 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -17,13 +17,12 @@ concurrency: # ubuntu-latest:docker://node:20-alpine ← CI jobs run in real Docker containers # self-hosted:host ← deploy runs directly on the server # -# All images/packages served from local Nexus at 171.22.25.73: -# Docker images → 171.22.25.73:8087 (docker-group connector: Docker Hub + MCR) -# NuGet → http://171.22.25.73:8081/repository/nuget-group/ -# npm → http://171.22.25.73:8081/repository/npm-group/ +# All images/packages served from Nexus at mirror.soroushasadi.com: +# Docker images → mirror.soroushasadi.com (docker-group: Docker Hub + MCR) +# NuGet → https://mirror.soroushasadi.com/repository/nuget-group/ +# npm → https://mirror.soroushasadi.com/repository/npm-group/ # -# The runner host is 171.22.25.73, so Nexus is always reachable directly. -# Daemon must have: "insecure-registries": ["171.22.25.73:8087"] +# Docker daemon: merge docker/daemon-registry-mirror.example.json into daemon.json # ───────────────────────────────────────────────────────────────────────────── jobs: @@ -32,12 +31,12 @@ jobs: name: "CI · API (dotnet build + test)" runs-on: ubuntu-latest container: - image: 171.22.25.73:8087/dotnet/sdk:10.0 + image: mirror.soroushasadi.com/dotnet/sdk:10.0 options: >- --add-host=gitea:host-gateway services: postgres: - image: 171.22.25.73:8087/postgres:16-alpine + image: mirror.soroushasadi.com/postgres:16-alpine env: POSTGRES_DB: meezi_test POSTGRES_USER: meezi @@ -48,7 +47,7 @@ jobs: --health-timeout 5s --health-retries 10 redis: - image: 171.22.25.73:8087/redis:7-alpine + image: mirror.soroushasadi.com/redis:7-alpine options: >- --health-cmd "redis-cli ping" --health-interval 5s @@ -74,9 +73,9 @@ jobs: + /> EOF @@ -99,7 +98,7 @@ jobs: name: "CI · Admin API (dotnet build)" runs-on: ubuntu-latest container: - image: 171.22.25.73:8087/dotnet/sdk:10.0 + image: mirror.soroushasadi.com/dotnet/sdk:10.0 options: >- --add-host=gitea:host-gateway steps: @@ -122,9 +121,9 @@ jobs: + /> EOF @@ -141,7 +140,7 @@ jobs: name: "CI · Dashboard (tsc)" runs-on: ubuntu-latest container: - image: 171.22.25.73:8087/node:20-alpine + image: mirror.soroushasadi.com/node:20-alpine options: >- --add-host=gitea:host-gateway steps: @@ -159,7 +158,7 @@ jobs: - name: Install dependencies working-directory: web/dashboard - run: npm install --legacy-peer-deps --ignore-scripts --registry http://171.22.25.73:8081/repository/npm-group/ + run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ - name: TypeScript check working-directory: web/dashboard @@ -171,7 +170,7 @@ jobs: name: "CI · Admin Web (tsc)" runs-on: ubuntu-latest container: - image: 171.22.25.73:8087/node:20-alpine + image: mirror.soroushasadi.com/node:20-alpine options: >- --add-host=gitea:host-gateway steps: @@ -189,7 +188,7 @@ jobs: - name: Install dependencies working-directory: web/admin - run: npm install --legacy-peer-deps --ignore-scripts --registry http://171.22.25.73:8081/repository/npm-group/ + run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ - name: TypeScript check working-directory: web/admin @@ -201,7 +200,7 @@ jobs: name: "CI · Website (tsc)" runs-on: ubuntu-latest container: - image: 171.22.25.73:8087/node:20-alpine + image: mirror.soroushasadi.com/node:20-alpine options: >- --add-host=gitea:host-gateway steps: @@ -219,7 +218,7 @@ jobs: - name: Install dependencies working-directory: web/website - run: npm install --legacy-peer-deps --ignore-scripts --registry http://171.22.25.73:8081/repository/npm-group/ + run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ - name: TypeScript check working-directory: web/website @@ -231,7 +230,7 @@ jobs: name: "CI · Koja (tsc)" runs-on: ubuntu-latest container: - image: 171.22.25.73:8087/node:20-alpine + image: mirror.soroushasadi.com/node:20-alpine options: >- --add-host=gitea:host-gateway steps: @@ -249,7 +248,7 @@ jobs: - name: Install dependencies working-directory: web/koja - run: npm install --legacy-peer-deps --ignore-scripts --registry http://171.22.25.73:8081/repository/npm-group/ + run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ - name: TypeScript check working-directory: web/koja diff --git a/DEPLOY.md b/DEPLOY.md index b26aecb..870bcab 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -6,7 +6,7 @@ Server: 171.22.25.73 │ ├── Gitea :3000 ← source control + CI runner -├── Nexus :8081 ← package mirror (NuGet, npm, Docker) +├── Nexus mirror.soroushasadi.com ← package mirror (NuGet, npm, Docker, MCR) │ ├── meezi-api :5080 ← .NET main API ├── meezi-admin-api:5081 ← .NET admin API @@ -128,7 +128,7 @@ CI takes ~5–10 minutes: builds 6 Docker images, runs all checks, then deploys. | Main API (Swagger) | http://171.22.25.73:5080/swagger | | Admin API (Swagger) | http://171.22.25.73:5081/swagger | | Gitea | http://171.22.25.73:3000 | -| Nexus | http://171.22.25.73:8081 | +| Nexus | https://mirror.soroushasadi.com/ | --- @@ -255,8 +255,8 @@ Nexus runs separately and should always be running: # Start (first time or after server reboot) docker compose -f docker-compose.mirror.yml up -d -# Health check -curl -s http://localhost:8081/service/rest/v1/status +# Health check (on server or via domain) +curl -s https://mirror.soroushasadi.com/service/rest/v1/status ``` Provisioned repos: diff --git a/docker-compose.admin.yml b/docker-compose.admin.yml index 56f1694..146d9b5 100644 --- a/docker-compose.admin.yml +++ b/docker-compose.admin.yml @@ -16,8 +16,8 @@ services: extra_hosts: - "mirror:host-gateway" args: - DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-171.22.25.73:8087/dotnet/sdk:10.0} - DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-171.22.25.73:8087/dotnet/aspnet:10.0} + DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mirror.soroushasadi.com/dotnet/sdk:10.0} + DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mirror.soroushasadi.com/dotnet/aspnet:10.0} container_name: meezi-admin-api restart: unless-stopped depends_on: @@ -52,8 +52,8 @@ services: extra_hosts: - "mirror:host-gateway" args: - NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:8087/node:20-alpine} - NPM_REGISTRY: ${NPM_REGISTRY:-http://171.22.25.73:8081/repository/npm-group/} + NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine} + NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/} NEXT_PUBLIC_ADMIN_API_URL: ${NEXT_PUBLIC_ADMIN_API_URL:-http://localhost:5081} container_name: meezi-admin-web restart: unless-stopped diff --git a/docker-compose.mirror.yml b/docker-compose.mirror.yml index c185e4c..68e9c7a 100644 --- a/docker-compose.mirror.yml +++ b/docker-compose.mirror.yml @@ -6,10 +6,10 @@ # ./mirrors/nexus/provision.sh # creates all proxy repos + enables anon access # # Endpoints (after provisioning): -# UI → http://SERVER_IP:8081 (admin / see provision.sh output) -# NuGet → http://SERVER_IP:8081/repository/nuget-proxy/index.json -# npm → http://SERVER_IP:8081/repository/npm-proxy/ -# Docker → http://SERVER_IP:5000 (add to /etc/docker/daemon.json) +# UI → https://mirror.soroushasadi.com/ (admin / see provision.sh output) +# NuGet → https://mirror.soroushasadi.com/repository/nuget-group/index.json +# npm → https://mirror.soroushasadi.com/repository/npm-group/ +# Docker → https://mirror.soroushasadi.com (add to daemon.json registry-mirrors) # # Memory: needs ~2 GB JVM heap — recommended on a server with 4 GB+ total RAM. # Adjust INSTALL4J_ADD_VM_PARAMS below if your server has more/less RAM. diff --git a/docker-compose.yml b/docker-compose.yml index bc5c5e7..66cc9aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,11 @@ # Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja) # -# All images/packages served from local Nexus at 171.22.25.73: -# Docker images → 171.22.25.73:8087 (docker-group connector: proxies Docker Hub + MCR) -# NuGet → http://171.22.25.73:8081/repository/nuget-group/ -# npm → http://171.22.25.73:8081/repository/npm-group/ +# All images/packages served from Nexus at mirror.soroushasadi.com: +# Docker images → mirror.soroushasadi.com (docker-group: Docker Hub + MCR) +# NuGet → https://mirror.soroushasadi.com/repository/nuget-group/ +# npm → https://mirror.soroushasadi.com/repository/npm-group/ # -# Docker Desktop: add "insecure-registries": ["171.22.25.73:8087"] to daemon.json -# (8087 is the Nexus Docker connector port; it serves images at the root path) +# Docker Desktop: merge docker/daemon-registry-mirror.example.json into daemon.json # # Local dev: # cp .env.example .env @@ -26,7 +25,7 @@ services: postgres: - image: ${POSTGRES_IMAGE:-171.22.25.73:8087/postgres:16-alpine} + image: ${POSTGRES_IMAGE:-mirror.soroushasadi.com/postgres:16-alpine} container_name: meezi-db restart: unless-stopped environment: @@ -44,7 +43,7 @@ services: retries: 10 redis: - image: ${REDIS_IMAGE:-171.22.25.73:8087/redis:7-alpine} + image: ${REDIS_IMAGE:-mirror.soroushasadi.com/redis:7-alpine} container_name: meezi-redis restart: unless-stopped ports: @@ -65,8 +64,8 @@ services: extra_hosts: - "mirror:host-gateway" args: - DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-171.22.25.73:8087/dotnet/sdk:10.0} - DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-171.22.25.73:8087/dotnet/aspnet:10.0} + DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mirror.soroushasadi.com/dotnet/sdk:10.0} + DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mirror.soroushasadi.com/dotnet/aspnet:10.0} container_name: meezi-api restart: unless-stopped depends_on: @@ -111,8 +110,8 @@ services: extra_hosts: - "mirror:host-gateway" args: - NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:8087/node:20-alpine} - NPM_REGISTRY: ${NPM_REGISTRY:-http://171.22.25.73:8081/repository/npm-group/} + NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine} + NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080} container_name: meezi-web restart: unless-stopped @@ -132,8 +131,8 @@ services: extra_hosts: - "mirror:host-gateway" args: - NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:8087/node:20-alpine} - NPM_REGISTRY: ${NPM_REGISTRY:-http://171.22.25.73:8081/repository/npm-group/} + NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine} + NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/} MEEZI_API_URL: http://api:8080 NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010} container_name: meezi-website @@ -156,8 +155,8 @@ services: extra_hosts: - "mirror:host-gateway" args: - NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:8087/node:20-alpine} - NPM_REGISTRY: ${NPM_REGISTRY:-http://171.22.25.73:8081/repository/npm-group/} + NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine} + NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103} container_name: meezi-koja diff --git a/docker/admin-api/Dockerfile b/docker/admin-api/Dockerfile index 7883b1f..de6928b 100644 --- a/docker/admin-api/Dockerfile +++ b/docker/admin-api/Dockerfile @@ -1,11 +1,11 @@ -ARG DOTNET_SDK_IMAGE=171.22.25.73:8087/dotnet/sdk:10.0 -ARG DOTNET_ASPNET_IMAGE=171.22.25.73:8087/dotnet/aspnet:10.0 +ARG DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0 +ARG DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0 FROM ${DOTNET_SDK_IMAGE} AS build WORKDIR /src COPY global.json Directory.Build.props Directory.Packages.props ./ -# nuget.docker.config points to local Nexus mirror (171.22.25.73:8081) +# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com) COPY nuget.docker.config ./nuget.config COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/ diff --git a/docker/admin-web/Dockerfile b/docker/admin-web/Dockerfile index 771b015..3d3c085 100644 --- a/docker/admin-web/Dockerfile +++ b/docker/admin-web/Dockerfile @@ -1,9 +1,9 @@ -ARG NODE_IMAGE=171.22.25.73:8087/node:20-alpine +ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine FROM ${NODE_IMAGE} AS deps WORKDIR /app COPY web/admin/package*.json ./ -ARG NPM_REGISTRY=http://171.22.25.73:8081/repository/npm-group/ +ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/ # Install deps then ensure Alpine (musl) SWC binary is present RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \ && NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \ diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 8142f77..70f299d 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -1,11 +1,11 @@ -ARG DOTNET_SDK_IMAGE=171.22.25.73:8087/dotnet/sdk:10.0 -ARG DOTNET_ASPNET_IMAGE=171.22.25.73:8087/dotnet/aspnet:10.0 +ARG DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0 +ARG DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0 FROM ${DOTNET_SDK_IMAGE} AS build WORKDIR /src COPY global.json Directory.Build.props Directory.Packages.props ./ -# nuget.docker.config points to local Nexus mirror (171.22.25.73:8081) +# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com) COPY nuget.docker.config ./nuget.config COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/ diff --git a/docker/daemon-registry-mirror.example.json b/docker/daemon-registry-mirror.example.json index b314262..92ca33a 100644 --- a/docker/daemon-registry-mirror.example.json +++ b/docker/daemon-registry-mirror.example.json @@ -1,8 +1,5 @@ { - "insecure-registries": [ - "171.22.25.73:8087" - ], "registry-mirrors": [ - "http://171.22.25.73:8087" + "https://mirror.soroushasadi.com" ] } diff --git a/docker/koja/Dockerfile b/docker/koja/Dockerfile index 6efb033..6541fe9 100644 --- a/docker/koja/Dockerfile +++ b/docker/koja/Dockerfile @@ -1,9 +1,9 @@ -ARG NODE_IMAGE=171.22.25.73:8087/node:20-alpine +ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine FROM ${NODE_IMAGE} AS deps WORKDIR /app COPY web/koja/package*.json ./ -ARG NPM_REGISTRY=http://171.22.25.73:8081/repository/npm-group/ +ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/ RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} FROM ${NODE_IMAGE} AS builder diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index cb4c2a9..ecb572e 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -1,9 +1,9 @@ -ARG NODE_IMAGE=171.22.25.73:8087/node:20-alpine +ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine FROM ${NODE_IMAGE} AS deps WORKDIR /app COPY web/dashboard/package*.json ./ -ARG NPM_REGISTRY=http://171.22.25.73:8081/repository/npm-group/ +ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/ RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} FROM ${NODE_IMAGE} AS builder diff --git a/docker/website/Dockerfile b/docker/website/Dockerfile index fce0327..9a5ac91 100644 --- a/docker/website/Dockerfile +++ b/docker/website/Dockerfile @@ -1,9 +1,9 @@ -ARG NODE_IMAGE=171.22.25.73:8087/node:20-alpine +ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine FROM ${NODE_IMAGE} AS deps WORKDIR /app COPY web/website/package*.json ./ -ARG NPM_REGISTRY=http://171.22.25.73:8081/repository/npm-group/ +ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/ # Install deps then ensure Alpine (musl) SWC binary is present RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \ && NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \ diff --git a/mirrors/nexus/add-liara-mirrors.sh b/mirrors/nexus/add-liara-mirrors.sh index 0a17aa8..12f541a 100644 --- a/mirrors/nexus/add-liara-mirrors.sh +++ b/mirrors/nexus/add-liara-mirrors.sh @@ -136,18 +136,18 @@ echo "════════════════════════ echo "🎉 Done!" echo "═══════════════════════════════════════════════════════════════" echo "" -echo " npm-group → http://SERVER:8081/repository/npm-group/" +echo " npm-group → https://mirror.soroushasadi.com/repository/npm-group/" echo " Liara first, Runflare as fallback" echo "" -echo " pypi-group → http://SERVER:8081/repository/pypi-group/" +echo " pypi-group → https://mirror.soroushasadi.com/repository/pypi-group/" echo " Liara first, Runflare as fallback" echo "" -echo " Ubuntu APT → http://SERVER:8081/repository/ubuntu-proxy/" +echo " Ubuntu APT → https://mirror.soroushasadi.com/repository/ubuntu-proxy/" echo " distribution: $UBUNTU_DIST" -echo " security: http://SERVER:8081/repository/ubuntu-security-proxy/" +echo " security: https://mirror.soroushasadi.com/repository/ubuntu-security-proxy/" echo "" echo "To use Ubuntu APT in a Dockerfile:" -echo " RUN echo 'deb http://SERVER:8081/repository/ubuntu-proxy/ $UBUNTU_DIST main restricted universe' > /etc/apt/sources.list && \\" -echo " echo 'deb http://SERVER:8081/repository/ubuntu-security-proxy/ $UBUNTU_DIST-security main restricted universe' >> /etc/apt/sources.list && \\" +echo " RUN echo 'deb https://mirror.soroushasadi.com/repository/ubuntu-proxy/ $UBUNTU_DIST main restricted universe' > /etc/apt/sources.list && \\" +echo " echo 'deb https://mirror.soroushasadi.com/repository/ubuntu-security-proxy/ $UBUNTU_DIST-security main restricted universe' >> /etc/apt/sources.list && \\" echo " apt-get update" echo "" diff --git a/mirrors/nexus/provision.sh b/mirrors/nexus/provision.sh index 7b91466..1ec68c5 100644 --- a/mirrors/nexus/provision.sh +++ b/mirrors/nexus/provision.sh @@ -176,12 +176,12 @@ echo "════════════════════════ echo "🎉 Nexus provisioned!" echo "═══════════════════════════════════════════════════════════════" echo "" -echo " UI → http://$(hostname -I | awk '{print $1}'):8081" +echo " UI → https://mirror.soroushasadi.com/" echo " admin / $ADMIN_PASS" echo "" -echo " NuGet → http://$(hostname -I | awk '{print $1}'):8081/repository/nuget-proxy/index.json" -echo " npm → http://$(hostname -I | awk '{print $1}'):8081/repository/npm-proxy/" -echo " Docker → http://$(hostname -I | awk '{print $1}'):8083 ← upstream: $DOCKER_UPSTREAM" +echo " NuGet → https://mirror.soroushasadi.com/repository/nuget-group/index.json" +echo " npm → https://mirror.soroushasadi.com/repository/npm-group/" +echo " Docker → https://mirror.soroushasadi.com ← upstream: $DOCKER_UPSTREAM" echo "" if [ -z "$DOCKER_USER" ]; then echo " 💡 To switch Docker upstream to Liara mirror (faster in Iran):" @@ -194,7 +194,7 @@ if [ -z "$DOCKER_USER" ]; then echo "" fi echo "To activate Docker Hub mirror on this server:" -echo " Edit /etc/docker/daemon.json:" -echo ' { "insecure-registries": ["'"$(hostname -I | awk '{print $1}'):8083"'"], "registry-mirrors": ["http://'"$(hostname -I | awk '{print $1}'):8083"'"] }' +echo " Merge docker/daemon-registry-mirror.example.json into /etc/docker/daemon.json" +echo ' { "registry-mirrors": ["https://mirror.soroushasadi.com"] }' echo " systemctl restart docker" echo "" diff --git a/nuget.docker.config b/nuget.docker.config index fd102e9..9c1bf4e 100644 --- a/nuget.docker.config +++ b/nuget.docker.config @@ -1,12 +1,11 @@ - + + value="https://mirror.soroushasadi.com/repository/nuget-group/index.json" + protocolVersion="3" /> diff --git a/nuget.mirror.config b/nuget.mirror.config index bd177cf..ef0b6cc 100644 --- a/nuget.mirror.config +++ b/nuget.mirror.config @@ -8,7 +8,7 @@ - + diff --git a/src/Meezi.API/Controllers/AuditController.cs b/src/Meezi.API/Controllers/AuditController.cs new file mode 100644 index 0000000..a7c1324 --- /dev/null +++ b/src/Meezi.API/Controllers/AuditController.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Meezi.API.Models.Audit; +using Meezi.Core.Authorization; +using Meezi.Core.Interfaces; +using Meezi.Infrastructure.Data; +using Meezi.Shared; + +namespace Meezi.API.Controllers; + +/// +/// Read-only access to the immutable POS / management audit trail. Gated by +/// ; branch-scoped sessions only ever see +/// their own branch's entries (enforced by the DB-level branch isolation filter), +/// café-wide owners see everything. +/// +[Route("api/cafes/{cafeId}/audit-logs")] +public class AuditController : CafeApiControllerBase +{ + private const int MaxPageSize = 100; + + private readonly AppDbContext _db; + + public AuditController(AppDbContext db) + { + _db = db; + } + + [HttpGet] + public async Task List( + string cafeId, + ITenantContext tenant, + CancellationToken ct, + [FromQuery] string? category = null, + [FromQuery] string? action = null, + [FromQuery] string? branchId = null, + [FromQuery] string? entityType = null, + [FromQuery] string? entityId = null, + [FromQuery] DateTime? from = null, + [FromQuery] DateTime? to = null, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ViewReports) is { } forbidden) return forbidden; + + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 50; + if (pageSize > MaxPageSize) pageSize = MaxPageSize; + + var query = _db.AuditLogs.AsNoTracking().Where(x => x.CafeId == cafeId); + + if (!string.IsNullOrWhiteSpace(category)) + query = query.Where(x => x.Category == category); + if (!string.IsNullOrWhiteSpace(action)) + query = query.Where(x => x.Action == action); + if (!string.IsNullOrWhiteSpace(branchId)) + query = query.Where(x => x.BranchId == branchId); + if (!string.IsNullOrWhiteSpace(entityType)) + query = query.Where(x => x.EntityType == entityType); + if (!string.IsNullOrWhiteSpace(entityId)) + query = query.Where(x => x.EntityId == entityId); + if (from is { } f) + query = query.Where(x => x.CreatedAt >= f); + if (to is { } t) + query = query.Where(x => x.CreatedAt <= t); + + var total = await query.CountAsync(ct); + + var items = await query + .OrderByDescending(x => x.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new AuditLogDto( + x.Id, + x.Category, + x.Action, + x.EntityType, + x.EntityId, + x.BranchId, + x.ActorId, + x.ActorName, + x.ActorRole, + x.Summary, + x.DetailsJson, + x.CreatedAt)) + .ToListAsync(ct); + + return Ok(new PagedApiResponse(true, items, new PagedMeta(total, page, pageSize))); + } +} diff --git a/src/Meezi.API/Controllers/AuthController.cs b/src/Meezi.API/Controllers/AuthController.cs index 529fb8f..5d089a6 100644 --- a/src/Meezi.API/Controllers/AuthController.cs +++ b/src/Meezi.API/Controllers/AuthController.cs @@ -90,6 +90,27 @@ public class AuthController : ControllerBase return Ok(new ApiResponse(true, data)); } + [HttpPost("switch-branch")] + [Authorize] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task SwitchBranch([FromBody] SwitchBranchRequest request, CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) + return Unauthorized(); + + var cafeId = User.FindFirstValue(MeeziClaimTypes.CafeId); + if (string.IsNullOrEmpty(cafeId)) + return Unauthorized(); + + var (success, data, code, message) = await _authService.SwitchBranchAsync(userId, cafeId, request.BranchId, cancellationToken); + if (!success) + return ErrorResult(code!, message!); + + return Ok(new ApiResponse(true, data)); + } + [HttpPost("refresh")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) @@ -178,6 +199,8 @@ public class AuthController : ControllerBase new ApiResponse(false, null, new ApiError(code, message))), "NOT_FOUND" => NotFound(new ApiResponse(false, null, new ApiError(code, message))), "INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse(false, null, new ApiError(code, message))), + "BRANCH_FORBIDDEN" => StatusCode(StatusCodes.Status403Forbidden, + new ApiResponse(false, null, new ApiError(code, message))), "ALREADY_REGISTERED" => Conflict(new ApiResponse(false, null, new ApiError(code, message))), _ => BadRequest(new ApiResponse(false, null, new ApiError(code, message))) }; diff --git a/src/Meezi.API/Controllers/CafeApiControllerBase.cs b/src/Meezi.API/Controllers/CafeApiControllerBase.cs index a2cae9a..02db159 100644 --- a/src/Meezi.API/Controllers/CafeApiControllerBase.cs +++ b/src/Meezi.API/Controllers/CafeApiControllerBase.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Meezi.Core.Authorization; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Shared; @@ -27,6 +28,50 @@ public abstract class CafeApiControllerBase : ControllerBase new ApiError("OWNER_REQUIRED", "Only the cafe owner can perform this action."))); } + /// Owner or Manager may act. + protected IActionResult? EnsureManager(ITenantContext tenant) + { + if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager) + return null; + return Forbidden("MANAGER_REQUIRED", "Manager access required."); + } + + /// The employee acting on their own record, or a manager/owner. + protected IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant) + { + if (tenant.UserId == employeeId) + return null; + return EnsureManager(tenant); + } + + /// Gate by an explicit capability from the role→permission matrix. + protected IActionResult? EnsurePermission(ITenantContext tenant, Permission permission) + { + if (tenant.Role is { } role && RolePermissions.Has(role, permission)) + return null; + return Forbidden("FORBIDDEN", "You do not have permission to perform this action."); + } + + /// + /// Strict branch isolation at the controller boundary: a branch-scoped session + /// may only touch its own branch. Café-wide sessions (Owner) and sessions with + /// no active branch are unrestricted here (DB query filters back this up). + /// + protected IActionResult? EnsureBranchAccess(string? routeBranchId, ITenantContext tenant) + { + if (tenant.Role is { } role && RolePermissions.IsCafeWide(role)) + return null; + if (string.IsNullOrEmpty(tenant.BranchId)) + return null; + if (string.IsNullOrEmpty(routeBranchId) || routeBranchId == tenant.BranchId) + return null; + return Forbidden("BRANCH_FORBIDDEN", "You do not have access to this branch."); + } + + private ObjectResult Forbidden(string code, string message) => + StatusCode(StatusCodes.Status403Forbidden, + new ApiResponse(false, null, new ApiError(code, message))); + protected static ApiResponse ValidationError(FluentValidation.Results.ValidationResult validation) { var first = validation.Errors.First(); diff --git a/src/Meezi.API/Controllers/HrController.cs b/src/Meezi.API/Controllers/HrController.cs index bdf27f1..0194606 100644 --- a/src/Meezi.API/Controllers/HrController.cs +++ b/src/Meezi.API/Controllers/HrController.cs @@ -201,21 +201,4 @@ public class HrController : CafeApiControllerBase if (data is null) return NotFoundError(); return Ok(new ApiResponse(true, data)); } - - private static IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant) - { - if (tenant.UserId == employeeId) return null; - return EnsureManager(tenant); - } - - private static IActionResult? EnsureManager(ITenantContext tenant) - { - if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager) - return null; - - return new ObjectResult(new ApiResponse(false, null, new ApiError("FORBIDDEN", "Manager access required."))) - { - StatusCode = StatusCodes.Status403Forbidden - }; - } } diff --git a/src/Meezi.API/Controllers/OrdersController.cs b/src/Meezi.API/Controllers/OrdersController.cs index 9bd1678..a8e8ef2 100644 --- a/src/Meezi.API/Controllers/OrdersController.cs +++ b/src/Meezi.API/Controllers/OrdersController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Meezi.API.Models.Orders; using Meezi.API.Services; +using Meezi.Core.Authorization; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Shared; @@ -13,6 +14,7 @@ namespace Meezi.API.Controllers; public class OrdersController : CafeApiControllerBase { private readonly IOrderService _orderService; + private readonly IAuditLogService _audit; private readonly IValidator _createValidator; private readonly IValidator _statusValidator; private readonly IValidator _paymentsValidator; @@ -21,6 +23,7 @@ public class OrdersController : CafeApiControllerBase public OrdersController( IOrderService orderService, + IAuditLogService audit, IValidator createValidator, IValidator statusValidator, IValidator paymentsValidator, @@ -28,6 +31,7 @@ public class OrdersController : CafeApiControllerBase IValidator sessionValidator) { _orderService = orderService; + _audit = audit; _createValidator = createValidator; _statusValidator = statusValidator; _paymentsValidator = paymentsValidator; @@ -131,6 +135,16 @@ public class OrdersController : CafeApiControllerBase if (!result.Success) return OrderError(result.ErrorCode!, result.Field); + await _audit.LogAsync(new AuditEntry + { + Category = "Order", + Action = "ItemVoided", + EntityType = "Order", + EntityId = id, + Summary = $"Voided a line item on order #{result.Data!.DisplayNumber}", + Details = new { orderId = id, itemId, displayNumber = result.Data.DisplayNumber } + }, cancellationToken); + return Ok(new ApiResponse(true, result.Data)); } @@ -188,6 +202,42 @@ public class OrdersController : CafeApiControllerBase return Ok(new ApiResponse(true, data)); } + [HttpPost("{id}/cancel")] + public async Task CancelOrder( + string cafeId, + string id, + [FromBody] CancelOrderRequest request, + ITenantContext tenant, + CancellationToken cancellationToken) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ProcessOrders) is { } forbidden) return forbidden; + + var result = await _orderService.CancelOrderAsync( + cafeId, id, request.Reason, tenant.UserId, cancellationToken); + if (!result.Success) + return OrderError(result.ErrorCode!, result.Field); + + await _audit.LogAsync(new AuditEntry + { + Category = "Order", + Action = "OrderCancelled", + EntityType = "Order", + EntityId = id, + Summary = $"Order #{result.Data!.DisplayNumber} cancelled" + + (string.IsNullOrWhiteSpace(request.Reason) ? "" : $": {request.Reason!.Trim()}"), + Details = new + { + orderId = id, + displayNumber = result.Data.DisplayNumber, + total = result.Data.Total, + reason = request.Reason + } + }, cancellationToken); + + return Ok(new ApiResponse(true, result.Data)); + } + [HttpPost("{id}/payments")] public async Task RecordPayments( string cafeId, @@ -203,6 +253,23 @@ public class OrdersController : CafeApiControllerBase var result = await _orderService.RecordPaymentsAsync( cafeId, id, request, tenant.UserId, cancellationToken); if (!result.Success) return OrderError(result.ErrorCode!, result.Field); + + var paidTotal = result.Data!.Sum(p => p.Amount); + await _audit.LogAsync(new AuditEntry + { + Category = "Payment", + Action = "PaymentRecorded", + EntityType = "Order", + EntityId = id, + Summary = $"Recorded payment(s) totalling {paidTotal:0.##} on order", + Details = new + { + orderId = id, + total = paidTotal, + methods = result.Data!.Select(p => new { p.Method, p.Amount }) + } + }, cancellationToken); + return Ok(new ApiResponse>(true, result.Data)); } @@ -219,6 +286,10 @@ public class OrdersController : CafeApiControllerBase false, null, new ApiError(code, "Order not found.", field))), "ORDER_ALREADY_CLOSED" => BadRequest(new ApiResponse( false, null, new ApiError(code, "Order is already closed.", field))), + "ORDER_ALREADY_CANCELLED" => BadRequest(new ApiResponse( + false, null, new ApiError(code, "Order is already cancelled.", field))), + "ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse( + false, null, new ApiError(code, "Refund the recorded payments before cancelling this order.", field))), "ITEM_NOT_FOUND" => NotFound(new ApiResponse( false, null, new ApiError(code, "Line item not found.", field))), "ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse( diff --git a/src/Meezi.API/Controllers/StaffBranchRolesController.cs b/src/Meezi.API/Controllers/StaffBranchRolesController.cs new file mode 100644 index 0000000..1ec8fb1 --- /dev/null +++ b/src/Meezi.API/Controllers/StaffBranchRolesController.cs @@ -0,0 +1,195 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Meezi.API.Models.Staff; +using Meezi.Core.Authorization; +using Meezi.Core.Entities; +using Meezi.Core.Enums; +using Meezi.Core.Interfaces; +using Meezi.Infrastructure.Data; +using Meezi.Shared; + +namespace Meezi.API.Controllers; + +/// +/// Manage the per-branch role assignments that drive the active-branch session model. +/// Owner/Manager gated; branch-scoped managers may only touch their own branch. +/// +[Route("api/cafes/{cafeId}/employees/{employeeId}/branch-roles")] +public class StaffBranchRolesController : CafeApiControllerBase +{ + private readonly AppDbContext _db; + private readonly Meezi.API.Services.IAuditLogService _audit; + + public StaffBranchRolesController(AppDbContext db, Meezi.API.Services.IAuditLogService audit) + { + _db = db; + _audit = audit; + } + + [HttpGet] + public async Task List( + string cafeId, + string employeeId, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden; + + var employeeExists = await _db.Employees + .AnyAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct); + if (!employeeExists) return NotFoundError("Employee not found."); + + var data = await _db.EmployeeBranchRoles + .Where(r => r.EmployeeId == employeeId && r.CafeId == cafeId && r.DeletedAt == null) + .Join(_db.Branches, r => r.BranchId, b => b.Id, (r, b) => new BranchRoleAssignmentDto(r.Id, b.Id, b.Name, r.Role)) + .OrderBy(d => d.BranchName) + .ToListAsync(ct); + + return Ok(new ApiResponse>(true, data)); + } + + [HttpPost] + public async Task Assign( + string cafeId, + string employeeId, + [FromBody] AssignBranchRoleRequest request, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden; + if (EnsureBranchAccess(request.BranchId, tenant) is { } branchDenied) return branchDenied; + + if (request.Role == EmployeeRole.Owner) + return BadRequest(Error("INVALID_ROLE", "Owners are café-wide and cannot be assigned to a branch.")); + + var employee = await _db.Employees + .FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct); + if (employee is null) return NotFoundError("Employee not found."); + if (employee.Role == EmployeeRole.Owner) + return BadRequest(Error("INVALID_ROLE", "The café owner cannot hold per-branch roles.")); + + var branchExists = await _db.Branches + .AnyAsync(b => b.Id == request.BranchId && b.CafeId == cafeId && b.DeletedAt == null, ct); + if (!branchExists) return NotFoundError("Branch not found."); + + var existing = await _db.EmployeeBranchRoles + .FirstOrDefaultAsync(r => r.EmployeeId == employeeId && r.BranchId == request.BranchId && r.DeletedAt == null, ct); + if (existing is not null) + return Conflict(new ApiResponse(false, null, + new ApiError("ALREADY_ASSIGNED", "This employee already has a role in this branch. Update it instead."))); + + var assignment = new EmployeeBranchRole + { + CafeId = cafeId, + EmployeeId = employeeId, + BranchId = request.BranchId, + Role = request.Role, + }; + _db.EmployeeBranchRoles.Add(assignment); + await _db.SaveChangesAsync(ct); + + var branchName = await _db.Branches + .Where(b => b.Id == request.BranchId) + .Select(b => b.Name) + .FirstAsync(ct); + + await _audit.LogAsync(new Meezi.API.Services.AuditEntry + { + Category = "Staff", + Action = "BranchRoleAssigned", + EntityType = "Employee", + EntityId = employeeId, + BranchId = request.BranchId, + Summary = $"Assigned {request.Role} role in {branchName} to {employee.Name}", + Details = new { employeeId, branchId = request.BranchId, role = request.Role.ToString() } + }, ct); + + return Ok(new ApiResponse(true, + new BranchRoleAssignmentDto(assignment.Id, request.BranchId, branchName, request.Role))); + } + + [HttpPatch("{assignmentId}")] + public async Task Update( + string cafeId, + string employeeId, + string assignmentId, + [FromBody] UpdateBranchRoleRequest request, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden; + + if (request.Role == EmployeeRole.Owner) + return BadRequest(Error("INVALID_ROLE", "Owners are café-wide and cannot be assigned to a branch.")); + + var assignment = await _db.EmployeeBranchRoles + .FirstOrDefaultAsync(r => r.Id == assignmentId && r.EmployeeId == employeeId + && r.CafeId == cafeId && r.DeletedAt == null, ct); + if (assignment is null) return NotFoundError("Branch role assignment not found."); + + if (EnsureBranchAccess(assignment.BranchId, tenant) is { } branchDenied) return branchDenied; + + assignment.Role = request.Role; + await _db.SaveChangesAsync(ct); + + var branchName = await _db.Branches + .Where(b => b.Id == assignment.BranchId) + .Select(b => b.Name) + .FirstAsync(ct); + + await _audit.LogAsync(new Meezi.API.Services.AuditEntry + { + Category = "Staff", + Action = "BranchRoleUpdated", + EntityType = "Employee", + EntityId = employeeId, + BranchId = assignment.BranchId, + Summary = $"Changed role to {request.Role} in {branchName}", + Details = new { employeeId, branchId = assignment.BranchId, role = request.Role.ToString() } + }, ct); + + return Ok(new ApiResponse(true, + new BranchRoleAssignmentDto(assignment.Id, assignment.BranchId, branchName, assignment.Role))); + } + + [HttpDelete("{assignmentId}")] + public async Task Remove( + string cafeId, + string employeeId, + string assignmentId, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden; + + var assignment = await _db.EmployeeBranchRoles + .FirstOrDefaultAsync(r => r.Id == assignmentId && r.EmployeeId == employeeId + && r.CafeId == cafeId && r.DeletedAt == null, ct); + if (assignment is null) return NotFoundError("Branch role assignment not found."); + + if (EnsureBranchAccess(assignment.BranchId, tenant) is { } branchDenied) return branchDenied; + + assignment.DeletedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + + await _audit.LogAsync(new Meezi.API.Services.AuditEntry + { + Category = "Staff", + Action = "BranchRoleRemoved", + EntityType = "Employee", + EntityId = employeeId, + BranchId = assignment.BranchId, + Summary = $"Removed {assignment.Role} branch role", + Details = new { employeeId, branchId = assignment.BranchId, role = assignment.Role.ToString() } + }, ct); + + return Ok(new ApiResponse(true, null)); + } + + private static ApiResponse Error(string code, string message) => + new(false, null, new ApiError(code, message)); +} diff --git a/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs b/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs index 6ad10be..d67a30f 100644 --- a/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs +++ b/src/Meezi.API/Extensions/ServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ public static class ServiceCollectionExtensions services.AddMeeziSecurity(configuration); services.AddInfrastructure(configuration); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Meezi.API/Models/Audit/AuditLogDtos.cs b/src/Meezi.API/Models/Audit/AuditLogDtos.cs new file mode 100644 index 0000000..696d164 --- /dev/null +++ b/src/Meezi.API/Models/Audit/AuditLogDtos.cs @@ -0,0 +1,16 @@ +namespace Meezi.API.Models.Audit; + +/// A single audit-trail entry as exposed to the dashboard. +public record AuditLogDto( + string Id, + string Category, + string Action, + string? EntityType, + string? EntityId, + string? BranchId, + string? ActorId, + string? ActorName, + string? ActorRole, + string Summary, + string? DetailsJson, + DateTime CreatedAt); diff --git a/src/Meezi.API/Models/Auth/AuthDtos.cs b/src/Meezi.API/Models/Auth/AuthDtos.cs index f172781..d34d8b5 100644 --- a/src/Meezi.API/Models/Auth/AuthDtos.cs +++ b/src/Meezi.API/Models/Auth/AuthDtos.cs @@ -8,6 +8,9 @@ public record RefreshTokenRequest(string RefreshToken); public record SwitchCafeRequest(string CafeId); +/// Switch the active branch within the current café. Null = café-wide (Owner only). +public record SwitchBranchRequest(string? BranchId); + /// Step 1 of self-registration: send OTP to a new phone number. public record RegisterRequest(string Phone, string CafeName); @@ -17,6 +20,9 @@ public record VerifyRegisterRequest(string Phone, string Code); /// One café membership entry returned when user belongs to multiple cafés. public record CafeMembershipDto(string CafeId, string CafeName, string Role, string PlanTier); +/// A branch the signed-in employee may operate as, with their role there. +public record BranchMembershipDto(string BranchId, string BranchName, string Role); + public record AuthTokenResponse( string AccessToken, string RefreshToken, @@ -28,7 +34,12 @@ public record AuthTokenResponse( string Language, string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant, string? BranchId = null, - List? Memberships = null); + List? Memberships = null, + string? BranchName = null, + bool IsCafeWide = false, + List? Branches = null, + /// Effective capabilities for the active role — drives client-side page/action gating. + List? Permissions = null); public record SendOtpResponse(bool Sent, int ExpiresInSeconds); diff --git a/src/Meezi.API/Models/Orders/OrderDtos.cs b/src/Meezi.API/Models/Orders/OrderDtos.cs index be33a88..379a470 100644 --- a/src/Meezi.API/Models/Orders/OrderDtos.cs +++ b/src/Meezi.API/Models/Orders/OrderDtos.cs @@ -62,6 +62,8 @@ public record CreateOrderRequest( public record UpdateOrderStatusRequest(OrderStatus Status); +public record CancelOrderRequest(string? Reason); + public record CreatePaymentRequest(PaymentMethod Method, decimal Amount, string? Reference); public record RecordPaymentsRequest( diff --git a/src/Meezi.API/Models/Staff/StaffBranchRoleDtos.cs b/src/Meezi.API/Models/Staff/StaffBranchRoleDtos.cs new file mode 100644 index 0000000..e854c98 --- /dev/null +++ b/src/Meezi.API/Models/Staff/StaffBranchRoleDtos.cs @@ -0,0 +1,16 @@ +using Meezi.Core.Enums; + +namespace Meezi.API.Models.Staff; + +/// A single per-branch role assignment for an employee. +public record BranchRoleAssignmentDto( + string Id, + string BranchId, + string BranchName, + EmployeeRole Role); + +/// Assign (or move) an employee into a branch with a specific role. +public record AssignBranchRoleRequest(string BranchId, EmployeeRole Role); + +/// Change the role an employee holds in an existing branch assignment. +public record UpdateBranchRoleRequest(EmployeeRole Role); diff --git a/src/Meezi.API/Services/AuditLogService.cs b/src/Meezi.API/Services/AuditLogService.cs new file mode 100644 index 0000000..d3a21e3 --- /dev/null +++ b/src/Meezi.API/Services/AuditLogService.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using Meezi.Core.Entities; +using Meezi.Core.Interfaces; +using Meezi.Infrastructure.Data; +using Microsoft.Extensions.DependencyInjection; + +namespace Meezi.API.Services; + +/// +/// Persists audit entries on a fresh, isolated so the +/// write never participates in (or rolls back with) the caller's transaction, and +/// swallows all failures — auditing must never break the recorded operation. +/// +public sealed class AuditLogService : IAuditLogService +{ + private static readonly JsonSerializerOptions DetailsJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + private readonly ITenantContext _tenant; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public AuditLogService( + ITenantContext tenant, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _tenant = tenant; + _scopeFactory = scopeFactory; + _logger = logger; + } + + public async Task LogAsync(AuditEntry entry, CancellationToken ct = default) + { + try + { + var cafeId = _tenant.CafeId; + if (string.IsNullOrEmpty(cafeId)) + { + _logger.LogWarning( + "Skipping audit log '{Category}/{Action}' — no cafe context.", + entry.Category, entry.Action); + return; + } + + var log = new AuditLog + { + CafeId = cafeId, + BranchId = entry.BranchId ?? _tenant.BranchId, + Category = entry.Category, + Action = entry.Action, + EntityType = entry.EntityType, + EntityId = entry.EntityId, + ActorId = _tenant.UserId, + ActorName = entry.ActorName, + ActorRole = _tenant.Role?.ToString(), + Summary = entry.Summary, + DetailsJson = entry.Details is null + ? null + : JsonSerializer.Serialize(entry.Details, DetailsJsonOptions) + }; + + // Fresh scope → fresh DbContext (café-wide, unfiltered) so this write is + // independent of the business operation's change-tracker and transaction. + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.AuditLogs.Add(log); + await db.SaveChangesAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to write audit log '{Category}/{Action}' for entity {EntityType}:{EntityId}.", + entry.Category, entry.Action, entry.EntityType, entry.EntityId); + } + } +} diff --git a/src/Meezi.API/Services/AuthService.cs b/src/Meezi.API/Services/AuthService.cs index b260a44..e75df95 100644 --- a/src/Meezi.API/Services/AuthService.cs +++ b/src/Meezi.API/Services/AuthService.cs @@ -1,5 +1,6 @@ using Meezi.API.Models.Auth; using Meezi.API.Security; +using Meezi.Core.Authorization; using Meezi.Core.Entities; using Meezi.Core.Enums; using Meezi.Core.Interfaces; @@ -156,7 +157,7 @@ public class AuthService : IAuthService .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, cancellationToken); + var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, null, cancellationToken); return (true, tokens, null, null, null); } @@ -187,7 +188,53 @@ public class AuthService : IAuthService .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) .ToList(); - var tokens = await IssueTokensAsync(targetEmployee, targetEmployee.Cafe, membershipDtos, cancellationToken); + var tokens = await IssueTokensAsync(targetEmployee, targetEmployee.Cafe, membershipDtos, null, cancellationToken); + return (true, tokens, null, null); + } + + public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchBranchAsync( + string employeeId, string cafeId, string? targetBranchId, + CancellationToken cancellationToken = default) + { + var employee = await _db.Employees + .Include(e => e.Cafe) + .FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, cancellationToken); + if (employee?.Cafe is null) + return (false, null, "NOT_FOUND", "User not found."); + + // null target = café-wide (Owner only) + if (string.IsNullOrWhiteSpace(targetBranchId)) + { + if (employee.Role != EmployeeRole.Owner) + return (false, null, "BRANCH_FORBIDDEN", "Only owners can operate café-wide."); + } + else + { + var branchExists = await _db.Branches + .AnyAsync(b => b.Id == targetBranchId && b.CafeId == cafeId && b.DeletedAt == null, cancellationToken); + if (!branchExists) + return (false, null, "NOT_FOUND", "Branch not found."); + + if (employee.Role != EmployeeRole.Owner) + { + var assigned = await _db.EmployeeBranchRoles + .AnyAsync(r => r.EmployeeId == employeeId && r.BranchId == targetBranchId && r.DeletedAt == null, cancellationToken); + if (!assigned && employee.BranchId != targetBranchId) + return (false, null, "BRANCH_FORBIDDEN", "You don't have access to this branch."); + } + } + + var allMemberships = await _db.Employees + .Include(e => e.Cafe) + .Where(e => e.Phone == employee.Phone && e.DeletedAt == null) + .ToListAsync(cancellationToken); + + var membershipDtos = allMemberships + .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, targetBranchId, cancellationToken); return (true, tokens, null, null); } @@ -218,7 +265,7 @@ public class AuthService : IAuthService .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, cancellationToken); + var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken); return (true, tokens, null, null); } @@ -341,7 +388,7 @@ public class AuthService : IAuthService { new(cafe.Id, cafe.Name, owner.Role.ToString(), cafe.PlanTier.ToString()) }; - var tokens = await IssueTokensAsync(owner, cafe, ownerMembership, cancellationToken); + var tokens = await IssueTokensAsync(owner, cafe, ownerMembership, null, cancellationToken); return (true, tokens, null, null); } @@ -360,9 +407,12 @@ public class AuthService : IAuthService Core.Entities.Employee employee, Core.Entities.Cafe cafe, List? memberships, + string? requestedBranchId, CancellationToken cancellationToken) { - var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe); + var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken); + + var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId); var refreshToken = _jwtTokenService.CreateRefreshToken(); var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30); @@ -371,24 +421,114 @@ public class AuthService : IAuthService new RefreshTokenPayload( employee.Id, cafe.Id, - employee.Role.ToString(), + resolution.EffectiveRole.ToString(), cafe.PlanTier.ToString(), cafe.PreferredLanguage, - Meezi.Core.Constants.MeeziActorKinds.Merchant), + Meezi.Core.Constants.MeeziActorKinds.Merchant, + resolution.ActiveBranchId), TimeSpan.FromDays(refreshDays), cancellationToken); + var permissions = Meezi.Core.Authorization.RolePermissions + .For(resolution.EffectiveRole) + .Select(p => p.ToString()) + .OrderBy(p => p) + .ToList(); + return new AuthTokenResponse( accessToken, refreshToken, _jwtTokenService.GetAccessTokenExpiry(), employee.Id, cafe.Id, - employee.Role.ToString(), + resolution.EffectiveRole.ToString(), cafe.PlanTier.ToString(), cafe.PreferredLanguage, Meezi.Core.Constants.MeeziActorKinds.Merchant, - employee.BranchId, - memberships); + resolution.ActiveBranchId, + memberships, + resolution.ActiveBranchName, + resolution.IsCafeWide, + resolution.Branches, + permissions); + } + + private sealed record BranchResolution( + EmployeeRole EffectiveRole, + string? ActiveBranchId, + string? ActiveBranchName, + bool IsCafeWide, + List Branches); + + /// + /// Determine the active branch, the role the employee holds there, and the + /// full list of branches they may operate as. Owners are café-wide by default + /// (null active branch) but may scope to a specific branch. Other staff are + /// resolved from their assignments, falling + /// back to the legacy single pin. + /// + private async Task ResolveBranchAsync( + Core.Entities.Employee employee, + Core.Entities.Cafe cafe, + string? requestedBranchId, + CancellationToken ct) + { + var cafeBranches = await _db.Branches + .Where(b => b.CafeId == cafe.Id && b.DeletedAt == null && b.IsActive) + .OrderBy(b => b.Name) + .Select(b => new { b.Id, b.Name }) + .ToListAsync(ct); + + var branchNames = cafeBranches.ToDictionary(b => b.Id, b => b.Name); + + // Owner = café-wide. May optionally scope to a branch when requested & valid. + if (employee.Role == EmployeeRole.Owner) + { + var ownerBranches = cafeBranches + .Select(b => new BranchMembershipDto(b.Id, b.Name, EmployeeRole.Owner.ToString())) + .ToList(); + + if (!string.IsNullOrWhiteSpace(requestedBranchId) && branchNames.TryGetValue(requestedBranchId, out var rname)) + return new BranchResolution(EmployeeRole.Owner, requestedBranchId, rname, false, ownerBranches); + + return new BranchResolution(EmployeeRole.Owner, null, null, true, ownerBranches); + } + + // Non-owner: explicit per-branch role assignments, plus the legacy pin as a fallback. + var assignments = await _db.EmployeeBranchRoles + .Where(r => r.EmployeeId == employee.Id && r.DeletedAt == null) + .Select(r => new { r.BranchId, r.Role }) + .ToListAsync(ct); + + var membershipMap = new Dictionary(); + foreach (var a in assignments) + membershipMap[a.BranchId] = a.Role; + + if (!string.IsNullOrWhiteSpace(employee.BranchId) && !membershipMap.ContainsKey(employee.BranchId)) + membershipMap[employee.BranchId] = employee.Role; + + var branches = membershipMap + .Where(kv => branchNames.ContainsKey(kv.Key)) + .Select(kv => new BranchMembershipDto(kv.Key, branchNames[kv.Key], kv.Value.ToString())) + .OrderBy(b => b.BranchName) + .ToList(); + + // 1. Honour an explicit, valid request. + if (!string.IsNullOrWhiteSpace(requestedBranchId) + && membershipMap.TryGetValue(requestedBranchId, out var reqRole) + && branchNames.TryGetValue(requestedBranchId, out var reqName)) + { + return new BranchResolution(reqRole, requestedBranchId, reqName, false, branches); + } + + // 2/3. One or many memberships → default to the first (frontend can switch). + if (branches.Count >= 1) + { + var first = branches[0]; + return new BranchResolution(membershipMap[first.BranchId], first.BranchId, first.BranchName, false, branches); + } + + // 4. No assignments and no pin → back-compat: café role, no branch claim (isolation off). + return new BranchResolution(employee.Role, null, null, false, branches); } } diff --git a/src/Meezi.API/Services/IAuditLogService.cs b/src/Meezi.API/Services/IAuditLogService.cs new file mode 100644 index 0000000..942d169 --- /dev/null +++ b/src/Meezi.API/Services/IAuditLogService.cs @@ -0,0 +1,33 @@ +namespace Meezi.API.Services; + +/// +/// One sensitive POS / management action to record. Actor and tenant fields are +/// resolved from the current request context when not supplied explicitly. +/// +public sealed record AuditEntry +{ + public required string Category { get; init; } + public required string Action { get; init; } + public required string Summary { get; init; } + public string? EntityType { get; init; } + public string? EntityId { get; init; } + + /// Optional branch override; defaults to the active branch from context. + public string? BranchId { get; init; } + + /// Optional structured payload — serialized to JSON. + public object? Details { get; init; } + + /// Optional actor name override (display only). + public string? ActorName { get; init; } +} + +/// +/// Writes immutable audit-trail entries for sensitive actions. Implementations +/// must never throw into the caller — a failed audit write must not abort the +/// business operation it records. +/// +public interface IAuditLogService +{ + Task LogAsync(AuditEntry entry, CancellationToken ct = default); +} diff --git a/src/Meezi.API/Services/IAuthService.cs b/src/Meezi.API/Services/IAuthService.cs index 9b84204..d1cfca6 100644 --- a/src/Meezi.API/Services/IAuthService.cs +++ b/src/Meezi.API/Services/IAuthService.cs @@ -20,6 +20,14 @@ public interface IAuthService string employeeId, string targetCafeId, CancellationToken cancellationToken = default); + /// + /// Re-issue a token scoped to a different branch within the current café. + /// null means café-wide (Owner only). + /// + Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchBranchAsync( + string employeeId, string cafeId, string? targetBranchId, + CancellationToken cancellationToken = default); + Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync( RefreshTokenRequest request, CancellationToken cancellationToken = default); diff --git a/src/Meezi.API/Services/IJwtTokenService.cs b/src/Meezi.API/Services/IJwtTokenService.cs index 852120c..2fc7266 100644 --- a/src/Meezi.API/Services/IJwtTokenService.cs +++ b/src/Meezi.API/Services/IJwtTokenService.cs @@ -6,6 +6,14 @@ namespace Meezi.API.Services; public interface IJwtTokenService { string CreateAccessToken(Employee employee, Cafe cafe); + + /// + /// Issue a token scoped to an active branch. The + /// is the role the employee holds in (or their + /// café-wide role when is null). + /// + string CreateAccessToken(Employee employee, Cafe cafe, EmployeeRole effectiveRole, string? activeBranchId); + string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa"); string CreateRefreshToken(); DateTime GetAccessTokenExpiry(); diff --git a/src/Meezi.API/Services/JwtTokenService.cs b/src/Meezi.API/Services/JwtTokenService.cs index 5348724..3780f39 100644 --- a/src/Meezi.API/Services/JwtTokenService.cs +++ b/src/Meezi.API/Services/JwtTokenService.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using System.Text; using Meezi.Core.Constants; using Meezi.Core.Entities; +using Meezi.Core.Enums; using ConsumerAccount = Meezi.Core.Entities.ConsumerAccount; using Microsoft.IdentityModel.Tokens; @@ -17,7 +18,10 @@ public class JwtTokenService : IJwtTokenService _configuration = configuration; } - public string CreateAccessToken(Employee employee, Cafe cafe) + public string CreateAccessToken(Employee employee, Cafe cafe) => + CreateAccessToken(employee, cafe, employee.Role, employee.BranchId); + + public string CreateAccessToken(Employee employee, Cafe cafe, EmployeeRole effectiveRole, string? activeBranchId) { var key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); var issuer = _configuration["Jwt:Issuer"] ?? "meezi"; @@ -28,14 +32,14 @@ public class JwtTokenService : IJwtTokenService { new(JwtRegisteredClaimNames.Sub, employee.Id), new(MeeziClaimTypes.CafeId, cafe.Id), - new(MeeziClaimTypes.Role, employee.Role.ToString()), + new(MeeziClaimTypes.Role, effectiveRole.ToString()), new(MeeziClaimTypes.PlanTier, cafe.PlanTier.ToString()), new(MeeziClaimTypes.Language, cafe.PreferredLanguage), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")) }; - if (!string.IsNullOrEmpty(employee.BranchId)) - claims.Add(new Claim(MeeziClaimTypes.BranchId, employee.BranchId)); + if (!string.IsNullOrEmpty(activeBranchId)) + claims.Add(new Claim(MeeziClaimTypes.BranchId, activeBranchId)); var credentials = new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), diff --git a/src/Meezi.API/Services/OrderService.cs b/src/Meezi.API/Services/OrderService.cs index c0ceca3..449a888 100644 --- a/src/Meezi.API/Services/OrderService.cs +++ b/src/Meezi.API/Services/OrderService.cs @@ -55,6 +55,12 @@ public interface IOrderService string targetTableId, CancellationToken cancellationToken = default); Task UpdateStatusAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default); + Task> CancelOrderAsync( + string cafeId, + string orderId, + string? reason, + string? cancelledByEmployeeId, + CancellationToken cancellationToken = default); Task>> RecordPaymentsAsync( string cafeId, string orderId, @@ -957,6 +963,53 @@ public class OrderService : IOrderService return await GetOrderAsync(cafeId, orderId, cancellationToken); } + public async Task> CancelOrderAsync( + string cafeId, + string orderId, + string? reason, + string? cancelledByEmployeeId, + CancellationToken cancellationToken = default) + { + var order = await _db.Orders + .Include(o => o.Payments) + .FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken); + + if (order is null) + return new OrderServiceResult(false, null, "ORDER_NOT_FOUND"); + + if (order.Status == OrderStatus.Cancelled) + return new OrderServiceResult(false, null, "ORDER_ALREADY_CANCELLED"); + + if (!OpenForPaymentStatuses.Contains(order.Status)) + return new OrderServiceResult(false, null, "ORDER_NOT_OPEN"); + + // A paid order must be refunded through the payment flow first — cancelling it + // here would silently strip the recorded money. Block and surface the reason. + if (order.Payments.Any(p => p.DeletedAt == null)) + return new OrderServiceResult(false, null, "ORDER_HAS_PAYMENTS"); + + order.Status = OrderStatus.Cancelled; + order.StatusUpdatedAt = DateTime.UtcNow; + order.CancelledAt = DateTime.UtcNow; + order.CancelReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(); + order.CancelledByEmployeeId = cancelledByEmployeeId; + await _db.SaveChangesAsync(cancellationToken); + + await _kdsNotifier.NotifyOrderStatusChangedAsync(cafeId, orderId, OrderStatus.Cancelled, cancellationToken); + if (!string.IsNullOrEmpty(order.TableId)) + await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken); + + await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Cancelled, cancellationToken); + + var loaded = await LoadOrderAsync(cafeId, orderId, cancellationToken); + if (loaded is not null) + await _orderNotifications.NotifyOrderStatusChangedAsync(loaded, cancellationToken); + + return loaded is null + ? new OrderServiceResult(false, null, "ORDER_NOT_FOUND") + : new OrderServiceResult(true, MapOrder(loaded)); + } + public async Task>> RecordPaymentsAsync( string cafeId, string orderId, diff --git a/src/Meezi.API/Services/RefreshTokenStore.cs b/src/Meezi.API/Services/RefreshTokenStore.cs index 53f7895..aeef946 100644 --- a/src/Meezi.API/Services/RefreshTokenStore.cs +++ b/src/Meezi.API/Services/RefreshTokenStore.cs @@ -9,7 +9,8 @@ public record RefreshTokenPayload( string Role, string PlanTier, string Language, - string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant); + string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant, + string? ActiveBranchId = null); public interface IRefreshTokenStore { diff --git a/src/Meezi.Core/Authorization/Permission.cs b/src/Meezi.Core/Authorization/Permission.cs new file mode 100644 index 0000000..4cd2b7e --- /dev/null +++ b/src/Meezi.Core/Authorization/Permission.cs @@ -0,0 +1,41 @@ +namespace Meezi.Core.Authorization; + +/// +/// Capabilities a café employee can be granted. These are the single source of +/// truth for authorization — controllers check a rather +/// than hard-coding role names, so the role→capability mapping lives in exactly +/// one place (). +/// +public enum Permission +{ + // Café-level administration (Owner only) + ManageCafeSettings, + ManageBilling, + ManageBranches, + + // Management (Owner + Manager) + ManageStaff, + ManageMenu, + ManageInventory, + ManageExpenses, + ManageTaxes, + ManageCoupons, + ManageReservations, + ManageTables, + ViewReports, + ReviewLeave, + ManageSalaries, + ManagePrintSettings, + + // Front-of-house operations + ProcessOrders, + HandlePayments, + OperateRegister, + ManageQueue, + + // Kitchen + ViewKitchen, + + // Delivery + HandleDelivery, +} diff --git a/src/Meezi.Core/Authorization/RolePermissions.cs b/src/Meezi.Core/Authorization/RolePermissions.cs new file mode 100644 index 0000000..9fc516d --- /dev/null +++ b/src/Meezi.Core/Authorization/RolePermissions.cs @@ -0,0 +1,76 @@ +using Meezi.Core.Enums; + +namespace Meezi.Core.Authorization; + +/// +/// The authoritative role→capability matrix. Change what a role can do here and +/// every controller that calls EnsurePermission updates automatically. +/// +public static class RolePermissions +{ + private static readonly IReadOnlyDictionary> Matrix = + new Dictionary> + { + [EmployeeRole.Owner] = AllPermissions(), + + [EmployeeRole.Manager] = new() + { + Permission.ManageStaff, + Permission.ManageMenu, + Permission.ManageInventory, + Permission.ManageExpenses, + Permission.ManageTaxes, + Permission.ManageCoupons, + Permission.ManageReservations, + Permission.ManageTables, + Permission.ViewReports, + Permission.ReviewLeave, + Permission.ManageSalaries, + Permission.ManagePrintSettings, + Permission.ProcessOrders, + Permission.HandlePayments, + Permission.OperateRegister, + Permission.ManageQueue, + Permission.ViewKitchen, + Permission.HandleDelivery, + }, + + [EmployeeRole.Cashier] = new() + { + Permission.ProcessOrders, + Permission.HandlePayments, + Permission.OperateRegister, + Permission.ManageQueue, + Permission.ManageReservations, + }, + + [EmployeeRole.Waiter] = new() + { + Permission.ProcessOrders, + Permission.ManageReservations, + Permission.ManageQueue, + }, + + [EmployeeRole.Chef] = new() + { + Permission.ViewKitchen, + }, + + [EmployeeRole.Delivery] = new() + { + Permission.HandleDelivery, + }, + }; + + public static bool Has(EmployeeRole role, Permission permission) => + Matrix.TryGetValue(role, out var set) && set.Contains(permission); + + public static IReadOnlySet For(EmployeeRole role) => + Matrix.TryGetValue(role, out var set) ? set : new HashSet(); + + /// True for roles that administer the whole café across all branches. + public static bool IsCafeWide(EmployeeRole role) => role == EmployeeRole.Owner; + + private static HashSet AllPermissions() => + new(Enum.GetValues()); +} diff --git a/src/Meezi.Core/Entities/AuditLog.cs b/src/Meezi.Core/Entities/AuditLog.cs new file mode 100644 index 0000000..2ab8bd6 --- /dev/null +++ b/src/Meezi.Core/Entities/AuditLog.cs @@ -0,0 +1,34 @@ +namespace Meezi.Core.Entities; + +/// +/// Immutable record of a sensitive POS / management action. Written by +/// IAuditLogService and never updated. Branch-scoped so the strict +/// branch isolation filter applies (café-wide sessions see all). +/// +public class AuditLog : TenantEntity +{ + /// High-level grouping, e.g. "Order", "Payment", "Register", "Staff". + public string Category { get; set; } = string.Empty; + + /// Specific action, e.g. "OrderCancelled", "ItemVoided", "PaymentRecorded". + public string Action { get; set; } = string.Empty; + + /// The entity acted upon, e.g. "Order", "Shift". + public string? EntityType { get; set; } + + /// Id of the affected entity. + public string? EntityId { get; set; } + + public string? BranchId { get; set; } + + /// Employee who performed the action (null for system/automated). + public string? ActorId { get; set; } + public string? ActorName { get; set; } + public string? ActorRole { get; set; } + + /// Human-readable one-line summary (already localized at write time or neutral). + public string Summary { get; set; } = string.Empty; + + /// Optional structured payload (before/after, amounts, reason) as JSON. + public string? DetailsJson { get; set; } +} diff --git a/src/Meezi.Core/Entities/Branch.cs b/src/Meezi.Core/Entities/Branch.cs index 151aefc..d8b6570 100644 --- a/src/Meezi.Core/Entities/Branch.cs +++ b/src/Meezi.Core/Entities/Branch.cs @@ -39,4 +39,7 @@ public class Branch : TenantEntity public ICollection Tables { get; set; } = []; public ICollection Orders { get; set; } = []; public ICollection Staff { get; set; } = []; + + /// Per-branch role assignments scoped to this branch. + public ICollection StaffRoles { get; set; } = []; } diff --git a/src/Meezi.Core/Entities/Employee.cs b/src/Meezi.Core/Entities/Employee.cs index 8add810..135c9d9 100644 --- a/src/Meezi.Core/Entities/Employee.cs +++ b/src/Meezi.Core/Entities/Employee.cs @@ -19,4 +19,7 @@ public class Employee : TenantEntity public ICollection Attendances { get; set; } = []; public ICollection Schedules { get; set; } = []; public ICollection LeaveRequests { get; set; } = []; + + /// Per-branch role assignments (multi-branch staff). Owners are café-wide and may have none. + public ICollection BranchRoles { get; set; } = []; } diff --git a/src/Meezi.Core/Entities/EmployeeBranchRole.cs b/src/Meezi.Core/Entities/EmployeeBranchRole.cs new file mode 100644 index 0000000..9d3d5b2 --- /dev/null +++ b/src/Meezi.Core/Entities/EmployeeBranchRole.cs @@ -0,0 +1,19 @@ +using Meezi.Core.Enums; + +namespace Meezi.Core.Entities; + +/// +/// Per-branch role assignment for an employee. An employee row is scoped to one café +/// (a "membership"); this join lets that same employee hold a different +/// in each branch they work at. +/// Owners remain café-wide via and need no rows here. +/// +public class EmployeeBranchRole : TenantEntity +{ + public string EmployeeId { get; set; } = string.Empty; + public string BranchId { get; set; } = string.Empty; + public EmployeeRole Role { get; set; } + + public Employee Employee { get; set; } = null!; + public Branch Branch { get; set; } = null!; +} diff --git a/src/Meezi.Core/Entities/Order.cs b/src/Meezi.Core/Entities/Order.cs index 7b50dc3..24df6e1 100644 --- a/src/Meezi.Core/Entities/Order.cs +++ b/src/Meezi.Core/Entities/Order.cs @@ -34,6 +34,12 @@ public class Order : TenantEntity /// JSON snapshot: driver, address, delivery ETA, etc. public string? DeliveryMetaJson { get; set; } + /// Reason captured when the order was cancelled (POS audit / accountability). + public string? CancelReason { get; set; } + /// Employee who cancelled the order (null for system/automated). + public string? CancelledByEmployeeId { get; set; } + public DateTime? CancelledAt { get; set; } + public Cafe Cafe { get; set; } = null!; public Branch? Branch { get; set; } public Table? Table { get; set; } diff --git a/src/Meezi.Infrastructure/Data/AppDbContext.cs b/src/Meezi.Infrastructure/Data/AppDbContext.cs index 2f06eae..72584cb 100644 --- a/src/Meezi.Infrastructure/Data/AppDbContext.cs +++ b/src/Meezi.Infrastructure/Data/AppDbContext.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Meezi.Core.Entities; +using Meezi.Core.Interfaces; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -8,8 +9,22 @@ namespace Meezi.Infrastructure.Data; public class AppDbContext : DbContext { - public AppDbContext(DbContextOptions options) : base(options) + // Strict branch isolation. When an active branch scope is present (a + // branch-scoped staff session), every branch-owned entity is filtered to that + // branch at the DB layer — independent of, and backing up, controller checks. + // Café-wide sessions (Owner / "all branches") and non-HTTP contexts (migrations, + // background jobs, seeders) leave the scope empty so nothing is filtered. + private readonly string? _branchScopeId; + private readonly bool _branchScoped; + + public AppDbContext(DbContextOptions options, IBranchContext? branch = null) + : base(options) { + if (branch is { HasBranch: true }) + { + _branchScopeId = branch.BranchId; + _branchScoped = true; + } } public DbSet Cafes => Set(); @@ -17,6 +32,7 @@ public class AppDbContext : DbContext public DbSet
Tables => Set
(); public DbSet TableSections => Set(); public DbSet Employees => Set(); + public DbSet EmployeeBranchRoles => Set(); public DbSet MenuCategories => Set(); public DbSet MenuItems => Set(); public DbSet BranchMenuItemOverrides => Set(); @@ -63,6 +79,9 @@ public class AppDbContext : DbContext // Push notifications (Pushe) public DbSet PushDevices => Set(); + // Immutable audit trail of sensitive POS / management actions. + public DbSet AuditLogs => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -120,7 +139,7 @@ public class AppDbContext : DbContext e.HasIndex(x => new { x.BranchId, x.Name }); e.HasIndex(x => x.CafeId); e.HasOne(x => x.Branch).WithMany(b => b.Sections).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); - e.HasQueryFilter(x => x.DeletedAt == null); + e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); }); modelBuilder.Entity
(e => @@ -134,7 +153,7 @@ public class AppDbContext : DbContext e.HasOne(x => x.Cafe).WithMany(c => c.Tables).HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Branch).WithMany(b => b.Tables).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict); e.HasOne(x => x.Section).WithMany(s => s.Tables).HasForeignKey(x => x.SectionId).OnDelete(DeleteBehavior.SetNull); - e.HasQueryFilter(x => x.DeletedAt == null); + e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); }); modelBuilder.Entity(e => @@ -149,6 +168,37 @@ public class AppDbContext : DbContext e.HasQueryFilter(x => x.DeletedAt == null); }); + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.HasIndex(x => new { x.EmployeeId, x.BranchId }) + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + e.HasIndex(x => new { x.CafeId, x.BranchId }); + e.HasOne(x => x.Employee).WithMany(emp => emp.BranchRoles) + .HasForeignKey(x => x.EmployeeId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Branch).WithMany(b => b.StaffRoles) + .HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); + e.HasQueryFilter(x => x.DeletedAt == null); + }); + + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.Category).HasMaxLength(64).IsRequired(); + e.Property(x => x.Action).HasMaxLength(96).IsRequired(); + e.Property(x => x.EntityType).HasMaxLength(64); + e.Property(x => x.EntityId).HasMaxLength(64); + e.Property(x => x.ActorName).HasMaxLength(160); + e.Property(x => x.ActorRole).HasMaxLength(32); + e.Property(x => x.Summary).HasMaxLength(500).IsRequired(); + e.HasIndex(x => new { x.CafeId, x.Category }); + e.HasIndex(x => new { x.CafeId, x.BranchId }); + e.HasIndex(x => new { x.CafeId, x.CreatedAt }); + e.HasOne().WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade); + e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); + }); + modelBuilder.Entity(e => { e.HasKey(x => x.Id); @@ -180,7 +230,7 @@ public class AppDbContext : DbContext e.HasIndex(x => x.CafeId); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.MenuItem).WithMany(m => m.BranchOverrides).HasForeignKey(x => x.MenuItemId).OnDelete(DeleteBehavior.Cascade); - e.HasQueryFilter(x => x.DeletedAt == null); + e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); }); modelBuilder.Entity(e => @@ -204,7 +254,7 @@ public class AppDbContext : DbContext e.HasOne(x => x.Customer).WithMany(c => c.Orders).HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Employee).WithMany(emp => emp.Orders).HasForeignKey(x => x.EmployeeId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Coupon).WithMany(c => c.Orders).HasForeignKey(x => x.CouponId).OnDelete(DeleteBehavior.SetNull); - e.HasQueryFilter(x => x.DeletedAt == null); + e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); }); modelBuilder.Entity(e => @@ -287,7 +337,7 @@ public class AppDbContext : DbContext e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.OpenedBy).WithMany().HasForeignKey(x => x.OpenedByUserId).OnDelete(DeleteBehavior.Restrict); e.HasOne(x => x.ClosedBy).WithMany().HasForeignKey(x => x.ClosedByUserId).OnDelete(DeleteBehavior.SetNull); - e.HasQueryFilter(x => x.DeletedAt == null); + e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); }); modelBuilder.Entity(e => @@ -298,7 +348,7 @@ public class AppDbContext : DbContext e.HasIndex(x => new { x.CafeId, x.BranchId }); e.HasOne(x => x.Shift).WithMany(s => s.Transactions).HasForeignKey(x => x.ShiftId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull); - e.HasQueryFilter(x => x.DeletedAt == null); + e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); }); modelBuilder.Entity(e => @@ -353,7 +403,7 @@ public class AppDbContext : DbContext e.HasIndex(x => new { x.CafeId, x.SortOrder }); e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull); - e.HasQueryFilter(x => x.DeletedAt == null); + e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); }); modelBuilder.Entity(e => @@ -414,7 +464,7 @@ public class AppDbContext : DbContext e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull); e.HasOne(x => x.Order).WithMany().HasForeignKey(x => x.OrderId).OnDelete(DeleteBehavior.SetNull); - e.HasQueryFilter(x => x.DeletedAt == null); + e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); }); modelBuilder.Entity(e => @@ -426,7 +476,7 @@ public class AppDbContext : DbContext e.HasIndex(x => new { x.CafeId, x.BranchId, x.CreatedAt }); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Shift).WithMany().HasForeignKey(x => x.ShiftId).OnDelete(DeleteBehavior.SetNull); - e.HasQueryFilter(x => x.DeletedAt == null); + e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); }); modelBuilder.Entity(e => @@ -457,7 +507,7 @@ public class AppDbContext : DbContext .HasConversion(topProductsConverter, topProductsComparer) .HasColumnType("jsonb"); e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); - e.HasQueryFilter(x => x.DeletedAt == null); + e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); }); modelBuilder.Entity(e => diff --git a/src/Meezi.Infrastructure/Data/Migrations/20260530114131_AddEmployeeBranchRole.Designer.cs b/src/Meezi.Infrastructure/Data/Migrations/20260530114131_AddEmployeeBranchRole.Designer.cs new file mode 100644 index 0000000..27e8dec --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260530114131_AddEmployeeBranchRole.Designer.cs @@ -0,0 +1,3203 @@ +// +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("20260530114131_AddEmployeeBranchRole")] + partial class AddEmployeeBranchRole + { + /// + 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.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("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("PinCode") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + 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("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("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + 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.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/20260530114131_AddEmployeeBranchRole.cs b/src/Meezi.Infrastructure/Data/Migrations/20260530114131_AddEmployeeBranchRole.cs new file mode 100644 index 0000000..3468e19 --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260530114131_AddEmployeeBranchRole.cs @@ -0,0 +1,83 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Meezi.Infrastructure.Data.Migrations +{ + /// + public partial class AddEmployeeBranchRole : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EmployeeBranchRoles", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + EmployeeId = table.Column(type: "text", nullable: false), + BranchId = table.Column(type: "text", nullable: false), + Role = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + CafeId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmployeeBranchRoles", x => x.Id); + table.ForeignKey( + name: "FK_EmployeeBranchRoles_Branches_BranchId", + column: x => x.BranchId, + principalTable: "Branches", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_EmployeeBranchRoles_Employees_EmployeeId", + column: x => x.EmployeeId, + principalTable: "Employees", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_EmployeeBranchRoles_BranchId", + table: "EmployeeBranchRoles", + column: "BranchId"); + + migrationBuilder.CreateIndex( + name: "IX_EmployeeBranchRoles_CafeId_BranchId", + table: "EmployeeBranchRoles", + columns: new[] { "CafeId", "BranchId" }); + + migrationBuilder.CreateIndex( + name: "IX_EmployeeBranchRoles_EmployeeId_BranchId", + table: "EmployeeBranchRoles", + columns: new[] { "EmployeeId", "BranchId" }, + unique: true, + filter: "\"DeletedAt\" IS NULL"); + + // Backfill: every existing branch-pinned, non-owner employee gets an + // explicit per-branch role row mirroring their current (BranchId, Role). + // Owners (Role = 0) and café-wide non-pinned staff (BranchId IS NULL) are + // left untouched — they remain café-wide via Employee.Role. + migrationBuilder.Sql(@" + INSERT INTO ""EmployeeBranchRoles"" + (""Id"", ""EmployeeId"", ""BranchId"", ""Role"", ""CafeId"", ""CreatedAt"") + SELECT replace(gen_random_uuid()::text, '-', ''), + e.""Id"", e.""BranchId"", e.""Role"", e.""CafeId"", now() + FROM ""Employees"" e + WHERE e.""BranchId"" IS NOT NULL + AND e.""DeletedAt"" IS NULL + AND e.""Role"" <> 0; + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EmployeeBranchRoles"); + } + } +} diff --git a/src/Meezi.Infrastructure/Data/Migrations/20260531060124_AddAuditLog.Designer.cs b/src/Meezi.Infrastructure/Data/Migrations/20260531060124_AddAuditLog.Designer.cs new file mode 100644 index 0000000..4e1c4bc --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260531060124_AddAuditLog.Designer.cs @@ -0,0 +1,3278 @@ +// +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("20260531060124_AddAuditLog")] + partial class AddAuditLog + { + /// + 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("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("PinCode") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + 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("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("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + 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/20260531060124_AddAuditLog.cs b/src/Meezi.Infrastructure/Data/Migrations/20260531060124_AddAuditLog.cs new file mode 100644 index 0000000..1813935 --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260531060124_AddAuditLog.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Meezi.Infrastructure.Data.Migrations +{ + /// + public partial class AddAuditLog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Category = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Action = table.Column(type: "character varying(96)", maxLength: 96, nullable: false), + EntityType = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + EntityId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + BranchId = table.Column(type: "text", nullable: true), + ActorId = table.Column(type: "text", nullable: true), + ActorName = table.Column(type: "character varying(160)", maxLength: 160, nullable: true), + ActorRole = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + Summary = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + DetailsJson = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + CafeId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + table.ForeignKey( + name: "FK_AuditLogs_Cafes_CafeId", + column: x => x.CafeId, + principalTable: "Cafes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_CafeId_BranchId", + table: "AuditLogs", + columns: new[] { "CafeId", "BranchId" }); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_CafeId_Category", + table: "AuditLogs", + columns: new[] { "CafeId", "Category" }); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_CafeId_CreatedAt", + table: "AuditLogs", + columns: new[] { "CafeId", "CreatedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditLogs"); + } + } +} diff --git a/src/Meezi.Infrastructure/Data/Migrations/20260531061500_AddOrderCancellationFields.Designer.cs b/src/Meezi.Infrastructure/Data/Migrations/20260531061500_AddOrderCancellationFields.Designer.cs new file mode 100644 index 0000000..507ede1 --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260531061500_AddOrderCancellationFields.Designer.cs @@ -0,0 +1,3287 @@ +// +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("20260531061500_AddOrderCancellationFields")] + partial class AddOrderCancellationFields + { + /// + 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("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("PinCode") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + 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("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + 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/20260531061500_AddOrderCancellationFields.cs b/src/Meezi.Infrastructure/Data/Migrations/20260531061500_AddOrderCancellationFields.cs new file mode 100644 index 0000000..e63f21e --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260531061500_AddOrderCancellationFields.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Meezi.Infrastructure.Data.Migrations +{ + /// + public partial class AddOrderCancellationFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CancelReason", + table: "Orders", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "CancelledAt", + table: "Orders", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "CancelledByEmployeeId", + table: "Orders", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CancelReason", + table: "Orders"); + + migrationBuilder.DropColumn( + name: "CancelledAt", + table: "Orders"); + + migrationBuilder.DropColumn( + name: "CancelledByEmployeeId", + table: "Orders"); + } + } +} diff --git a/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 769cb20..0e89955 100644 --- a/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -57,6 +57,72 @@ namespace Meezi.Infrastructure.Data.Migrations 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") @@ -884,6 +950,45 @@ namespace Meezi.Infrastructure.Data.Migrations 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") @@ -1317,6 +1422,15 @@ namespace Meezi.Infrastructure.Data.Migrations .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"); @@ -2424,6 +2538,15 @@ namespace Meezi.Infrastructure.Data.Migrations 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") @@ -2565,6 +2688,25 @@ namespace Meezi.Infrastructure.Data.Migrations 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") @@ -3012,6 +3154,8 @@ namespace Meezi.Infrastructure.Data.Migrations b.Navigation("Staff"); + b.Navigation("StaffRoles"); + b.Navigation("Tables"); }); @@ -3061,6 +3205,8 @@ namespace Meezi.Infrastructure.Data.Migrations { b.Navigation("Attendances"); + b.Navigation("BranchRoles"); + b.Navigation("LeaveRequests"); b.Navigation("Orders"); diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index ea33950..0b3bb61 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -56,6 +56,29 @@ "delivery": "عامل التوصيل", "unknown": "مستخدم" }, + "branchSwitcher": { + "title": "الفرع النشط", + "allBranches": "كل الفروع", + "selectBranch": "اختر الفرع" + }, + "branchAccess": { + "title": "صلاحيات الفروع", + "staff": "الموظفون", + "noStaff": "لا يوجد موظفون بعد", + "selectStaff": "اختر موظفًا لإدارة الصلاحيات", + "ownerNote": "المالك لديه صلاحية الوصول لكل الفروع ولا يحتاج إلى أدوار خاصة بكل فرع.", + "noAssignments": "لم يتم تعيين أي دور للفروع بعد", + "loading": "جارٍ التحميل...", + "branch": "الفرع", + "role": "الدور", + "selectBranch": "اختر الفرع", + "add": "إضافة", + "remove": "حذف" + }, + "access": { + "deniedTitle": "لا تملك صلاحية الوصول إلى هذه الصفحة", + "deniedBody": "دورك لا يملك صلاحية عرض هذه الصفحة. تواصل مع المدير أو المالك إذا كنت بحاجة إلى الوصول." + }, "nav": { "aria": "القائمة الرئيسية", "collapseSidebar": "طي الشريط الجانبي", @@ -163,6 +186,8 @@ "cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.", "cancelOrderSuccess": "تم إلغاء الطلب", "cancelOrderError": "تعذّر إلغاء الطلب", + "cancelReasonPlaceholder": "سبب الإلغاء (اختياري)", + "cancelOrderHasPayments": "استرجع المدفوعات المسجّلة أولاً ثم ألغِ الطلب", "itemsCount": "صنف", "applyCoupon": "تطبيق القسيمة", "couponPlaceholder": "رمز القسيمة", @@ -360,7 +385,8 @@ "tabs": { "attendance": "الحضور", "leave": "الإجازة", - "payroll": "الرواتب" + "payroll": "الرواتب", + "access": "صلاحيات الفروع" }, "myAttendance": "حضوري", "clockIn": "تسجيل دخول", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 539dd30..4dc6e41 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -67,6 +67,29 @@ "delivery": "Delivery", "unknown": "User" }, + "branchSwitcher": { + "title": "Active branch", + "allBranches": "All branches", + "selectBranch": "Select branch" + }, + "branchAccess": { + "title": "Branch access", + "staff": "Staff", + "noStaff": "No staff yet", + "selectStaff": "Select a staff member to manage access", + "ownerNote": "The owner has access to all branches and does not need per-branch roles.", + "noAssignments": "No branch roles assigned yet", + "loading": "Loading...", + "branch": "Branch", + "role": "Role", + "selectBranch": "Select branch", + "add": "Add", + "remove": "Remove" + }, + "access": { + "deniedTitle": "No access to this page", + "deniedBody": "Your role doesn't have permission to view this page. Contact a manager or owner if you need access." + }, "nav": { "aria": "Main navigation", "collapseSidebar": "Collapse sidebar", @@ -182,6 +205,8 @@ "cancelOrderConfirm": "Customer left without paying? The order will be cancelled and the table freed.", "cancelOrderSuccess": "Order cancelled", "cancelOrderError": "Could not cancel order", + "cancelReasonPlaceholder": "Cancellation reason (optional)", + "cancelOrderHasPayments": "Refund the recorded payments first, then cancel the order", "itemsCount": "items", "applyCoupon": "Apply coupon", "couponPlaceholder": "Coupon code", @@ -379,7 +404,8 @@ "tabs": { "attendance": "Attendance", "leave": "Leave", - "payroll": "Payroll" + "payroll": "Payroll", + "access": "Branch access" }, "myAttendance": "My attendance", "clockIn": "Clock in", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index 94b33c0..808c502 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -67,6 +67,29 @@ "delivery": "پیک", "unknown": "کاربر" }, + "branchSwitcher": { + "title": "شعبه فعال", + "allBranches": "همه شعب", + "selectBranch": "انتخاب شعبه" + }, + "branchAccess": { + "title": "دسترسی شعب", + "staff": "کارکنان", + "noStaff": "کارمندی ثبت نشده است", + "selectStaff": "یک کارمند را برای مدیریت دسترسی انتخاب کنید", + "ownerNote": "مالک به همه شعب دسترسی دارد و نیازی به تعیین نقش شعبه‌ای ندارد.", + "noAssignments": "هنوز نقشی برای شعبه‌ای تعیین نشده است", + "loading": "در حال بارگذاری...", + "branch": "شعبه", + "role": "نقش", + "selectBranch": "انتخاب شعبه", + "add": "افزودن", + "remove": "حذف" + }, + "access": { + "deniedTitle": "دسترسی به این صفحه ندارید", + "deniedBody": "نقش شما اجازه مشاهده این صفحه را ندارد. در صورت نیاز با مدیر یا مالک هماهنگ کنید." + }, "nav": { "aria": "منوی اصلی", "collapseSidebar": "جمع کردن نوار کناری", @@ -182,6 +205,8 @@ "cancelOrderConfirm": "مشتری بدون پرداخت رفته است؟ سفارش لغو می‌شود و میز آزاد می‌شود.", "cancelOrderSuccess": "سفارش لغو شد", "cancelOrderError": "لغو سفارش ناموفق بود", + "cancelReasonPlaceholder": "دلیل لغو (اختیاری)", + "cancelOrderHasPayments": "ابتدا پرداخت‌های ثبت‌شده را بازگردانید، سپس سفارش را لغو کنید", "itemsCount": "قلم", "applyCoupon": "اعمال کوپن", "couponPlaceholder": "کد کوپن", @@ -379,7 +404,8 @@ "tabs": { "attendance": "حضور و غیاب", "leave": "مرخصی", - "payroll": "حقوق" + "payroll": "حقوق", + "access": "دسترسی شعب" }, "myAttendance": "حضور من", "clockIn": "ورود", diff --git a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx index f433341..cb96a59 100644 --- a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx +++ b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx @@ -5,6 +5,7 @@ import { useLocale } from "next-intl"; import { useRouter } from "@/i18n/routing"; import { Sidebar } from "@/components/layout/sidebar"; import { Topbar } from "@/components/layout/topbar"; +import { RouteGuard } from "@/components/auth/route-guard"; import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider"; import { useAuthStore } from "@/lib/stores/auth.store"; import { useOfflineSync } from "@/lib/offline/use-offline-sync"; @@ -31,7 +32,7 @@ export default function DashboardLayout({
- {children} + {children}
); diff --git a/web/dashboard/src/app/[locale]/(fullscreen)/layout.tsx b/web/dashboard/src/app/[locale]/(fullscreen)/layout.tsx index 668af83..e6373ea 100644 --- a/web/dashboard/src/app/[locale]/(fullscreen)/layout.tsx +++ b/web/dashboard/src/app/[locale]/(fullscreen)/layout.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { useLocale } from "next-intl"; import { useRouter } from "@/i18n/routing"; import { useAuthStore } from "@/lib/stores/auth.store"; +import { RouteGuard } from "@/components/auth/route-guard"; /** Full-viewport routes (queue TV display) — auth only, no dashboard chrome. */ export default function FullscreenLayout({ children }: { children: React.ReactNode }) { @@ -19,7 +20,7 @@ export default function FullscreenLayout({ children }: { children: React.ReactNo return (
- {children} + {children}
); } diff --git a/web/dashboard/src/components/auth/can.tsx b/web/dashboard/src/components/auth/can.tsx new file mode 100644 index 0000000..464d53e --- /dev/null +++ b/web/dashboard/src/components/auth/can.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useHasPermission, type Permission } from "@/lib/permissions"; + +/** + * Renders {@link children} only when the current user holds {@link permission}. + * For action-level RBAC (buttons, menu entries). The server still enforces the + * real check — this just hides controls the user can't use. + * + * @example + * + * + * + */ +export function Can({ + permission, + children, + fallback = null, +}: { + permission: Permission; + children: ReactNode; + fallback?: ReactNode; +}) { + const allowed = useHasPermission(permission); + return <>{allowed ? children : fallback}; +} diff --git a/web/dashboard/src/components/auth/route-guard.tsx b/web/dashboard/src/components/auth/route-guard.tsx new file mode 100644 index 0000000..d8daa3f --- /dev/null +++ b/web/dashboard/src/components/auth/route-guard.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useMemo } from "react"; +import { ShieldX } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { usePathname } from "@/i18n/routing"; +import { NAV_GROUPS, type NavItemKey } from "@/lib/sidebar-nav"; +import { NAV_REQUIRED_PERMISSION } from "@/lib/permissions"; +import { canSeeNavItem } from "@/lib/auth-permissions"; +import { permissionsOf } from "@/lib/permissions"; +import { useAuthStore } from "@/lib/stores/auth.store"; + +/** Resolve the nav item key that owns the given pathname (locale already stripped). */ +function navKeyForPath(pathname: string): NavItemKey | null { + for (const group of NAV_GROUPS) { + for (const item of group.items) { + if (pathname === item.href || pathname.startsWith(`${item.href}/`)) { + return item.key; + } + } + } + return null; +} + +/** + * Page-level access gate for direct-URL navigation. Mirrors the sidebar's + * visibility rules so a user who types a URL for a page they can't access sees a + * friendly notice instead of an empty or erroring screen. The API still enforces + * the real permission server-side. + */ +export function RouteGuard({ children }: { children: React.ReactNode }) { + const t = useTranslations("access"); + const pathname = usePathname(); + const user = useAuthStore((s) => s.user); + const hasHydrated = useAuthStore((s) => s._hasHydrated); + + const allowed = useMemo(() => { + const key = navKeyForPath(pathname); + // Pages outside the nav map (e.g. detail routes without a gated key) pass through. + if (!key) return true; + if (!NAV_REQUIRED_PERMISSION[key]) { + // No permission mapping — defer to role/branch visibility rules. + return canSeeNavItem(key, user?.role, user?.branchId ?? null, permissionsOf(user)); + } + return canSeeNavItem(key, user?.role, user?.branchId ?? null, permissionsOf(user)); + }, [pathname, user]); + + // Avoid flashing the denied panel before the persisted auth state rehydrates. + if (!hasHydrated) return <>{children}; + if (allowed) return <>{children}; + + return ( +
+ + + +

{t("deniedTitle")}

+

{t("deniedBody")}

+
+ ); +} diff --git a/web/dashboard/src/components/hr/branch-access-panel.tsx b/web/dashboard/src/components/hr/branch-access-panel.tsx new file mode 100644 index 0000000..260f5ea --- /dev/null +++ b/web/dashboard/src/components/hr/branch-access-panel.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { Trash2, Plus } from "lucide-react"; +import { apiGet } from "@/lib/api/client"; +import { + listBranchRoles, + assignBranchRole, + updateBranchRole, + removeBranchRole, + type BranchRoleAssignment, +} from "@/lib/api/branch-roles"; +import { roleKey } from "@/lib/role-label"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +interface Employee { + id: string; + name: string; + phone: string; + role: string; +} + +interface Branch { + id: string; + name: string; +} + +/** Branch-level roles an employee can be assigned (Owner is café-wide, excluded). */ +const ASSIGNABLE_ROLES = ["Manager", "Cashier", "Waiter", "Chef", "Delivery"] as const; + +export function BranchAccessPanel({ cafeId }: { cafeId: string }) { + const t = useTranslations("branchAccess"); + const tRoles = useTranslations("roles"); + const queryClient = useQueryClient(); + const [selectedId, setSelectedId] = useState(null); + const [newBranchId, setNewBranchId] = useState(""); + const [newRole, setNewRole] = useState("Cashier"); + + const { data: employees = [] } = useQuery({ + queryKey: ["employees", cafeId], + queryFn: () => apiGet(`/api/cafes/${cafeId}/employees`), + enabled: !!cafeId, + }); + + const { data: branches = [] } = useQuery({ + queryKey: ["branches", cafeId], + queryFn: () => apiGet(`/api/cafes/${cafeId}/branches`), + enabled: !!cafeId, + }); + + const selected = employees.find((e) => e.id === selectedId) ?? null; + const isOwner = selected?.role === "Owner"; + + const { data: assignments = [], isPending: loadingAssignments } = useQuery({ + queryKey: ["branch-roles", cafeId, selectedId], + queryFn: () => listBranchRoles(cafeId, selectedId!), + enabled: !!cafeId && !!selectedId && !isOwner, + }); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["branch-roles", cafeId, selectedId] }); + + const assign = useMutation({ + mutationFn: () => assignBranchRole(cafeId, selectedId!, { branchId: newBranchId, role: newRole }), + onSuccess: () => { + setNewBranchId(""); + invalidate(); + }, + }); + + const update = useMutation({ + mutationFn: (vars: { assignmentId: string; role: string }) => + updateBranchRole(cafeId, selectedId!, vars.assignmentId, vars.role), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationFn: (assignmentId: string) => removeBranchRole(cafeId, selectedId!, assignmentId), + onSuccess: invalidate, + }); + + const assignedBranchIds = useMemo( + () => new Set(assignments.map((a) => a.branchId)), + [assignments] + ); + const availableBranches = branches.filter((b) => !assignedBranchIds.has(b.id)); + + return ( +
+ {/* Employee picker */} + + + {t("staff")} + + + {employees.length === 0 ? ( +

{t("noStaff")}

+ ) : ( + employees.map((e) => ( + + )) + )} +
+
+ + {/* Assignment editor */} + + + {t("title")} + + + {!selected ? ( +

{t("selectStaff")}

+ ) : isOwner ? ( +

{t("ownerNote")}

+ ) : ( + <> + {loadingAssignments ? ( +

{t("loading")}

+ ) : assignments.length === 0 ? ( +

{t("noAssignments")}

+ ) : ( +
    + {assignments.map((a: BranchRoleAssignment) => ( +
  • + + {a.branchName} + + + +
  • + ))} +
+ )} + + {/* Add new assignment */} +
+
+ + +
+
+ + +
+ +
+ + )} +
+
+
+ ); +} diff --git a/web/dashboard/src/components/hr/hr-screen.tsx b/web/dashboard/src/components/hr/hr-screen.tsx index 8c5e7f4..16f6da4 100644 --- a/web/dashboard/src/components/hr/hr-screen.tsx +++ b/web/dashboard/src/components/hr/hr-screen.tsx @@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input"; 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"; interface Employee { id: string; @@ -46,12 +47,14 @@ interface Salary { isPaid: boolean; } -type Tab = "attendance" | "leave" | "payroll"; +type Tab = "attendance" | "leave" | "payroll" | "access"; export function HrScreen() { const t = useTranslations("hr"); const cafeId = useAuthStore((s) => s.user?.cafeId); const userId = useAuthStore((s) => s.user?.userId); + const role = useAuthStore((s) => s.user?.role); + const canManageAccess = role === "Owner" || role === "Manager"; const queryClient = useQueryClient(); const [tab, setTab] = useState("attendance"); const [monthYear, setMonthYear] = useState( @@ -119,7 +122,9 @@ export function HrScreen() {

{t("title")}

- {(["attendance", "leave", "payroll"] as Tab[]).map((key) => ( + {((["attendance", "leave", "payroll", "access"] as Tab[]).filter( + (key) => key !== "access" || canManageAccess + )).map((key) => (
); } diff --git a/web/dashboard/src/components/layout/branch-switcher.tsx b/web/dashboard/src/components/layout/branch-switcher.tsx new file mode 100644 index 0000000..7c14f4a --- /dev/null +++ b/web/dashboard/src/components/layout/branch-switcher.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Building2, Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { useAuthStore } from "@/lib/stores/auth.store"; +import { switchBranch } from "@/lib/api/branch-roles"; +import { isCafeOwner } from "@/lib/auth-permissions"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +/** + * Active-branch session switcher. Calls /auth/switch-branch which re-issues a + * token scoped to the chosen branch (and the role held there). Owners may also + * pick "all branches" (café-wide). Hidden when the employee has a single branch + * and is not the owner. + */ +export function BranchSwitcher() { + const t = useTranslations("branchSwitcher"); + const user = useAuthStore((s) => s.user); + const setAuth = useAuthStore((s) => s.setAuth); + const [pending, setPending] = useState(false); + + const branches = user?.branches ?? []; + const owner = isCafeOwner(user?.role); + + // Owners always get the switcher (to scope into a branch); other staff only + // when they actually belong to more than one branch. + if (!user || (!owner && branches.length <= 1)) return null; + + const activeLabel = user.isCafeWide + ? t("allBranches") + : user.branchName ?? t("selectBranch"); + + async function choose(branchId: string | null) { + if (pending) return; + // No-op when re-selecting the current scope. + if (branchId === (user!.branchId ?? null)) return; + setPending(true); + try { + const next = await switchBranch(branchId); + setAuth(next); + // Active branch changes nearly every scoped query + nav — full reload is safest. + if (typeof window !== "undefined") window.location.reload(); + } finally { + setPending(false); + } + } + + return ( + + + + + +

{t("title")}

+
+ {owner && ( + choose(null)} + className="cursor-pointer gap-2" + > + + {t("allBranches")} + + )} + {branches.map((b) => ( + choose(b.branchId)} + className="cursor-pointer gap-2" + > + + {b.branchName} + + ))} + + + ); +} diff --git a/web/dashboard/src/components/layout/sidebar.tsx b/web/dashboard/src/components/layout/sidebar.tsx index de88055..c54df74 100644 --- a/web/dashboard/src/components/layout/sidebar.tsx +++ b/web/dashboard/src/components/layout/sidebar.tsx @@ -5,6 +5,7 @@ import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import { useTranslations } from "next-intl"; import { Link, usePathname } from "@/i18n/routing"; import { canSeeNavGroup, canSeeNavItem } from "@/lib/auth-permissions"; +import { permissionsOf } from "@/lib/permissions"; import { NAV_GROUPS, NAV_GROUPS_STORAGE_KEY, @@ -102,6 +103,7 @@ function NavGroupSection({ pathname, role, branchId, + permissions, tItem, collapsed, }: { @@ -112,11 +114,12 @@ function NavGroupSection({ pathname: string; role: string | undefined; branchId: string | null | undefined; + permissions: Set | null; tItem: (key: string) => string; collapsed: boolean; }) { const visibleItems = group.items.filter((item) => - canSeeNavItem(item.key, role, branchId) + canSeeNavItem(item.key, role, branchId, permissions) ); if (visibleItems.length === 0) return null; @@ -198,6 +201,7 @@ export function Sidebar({ side }: { side: "left" | "right" }) { const hasHydrated = useAuthStore((s) => s._hasHydrated); const role = user?.role; const branchId = user?.branchId ?? null; + const permissions = useMemo(() => permissionsOf(user), [user]); const [openGroups, setOpenGroups] = useState(buildDefaultOpenGroups); const [collapsed, setCollapsed] = useState(readStoredCollapsed); @@ -229,9 +233,9 @@ export function Sidebar({ side }: { side: "left" | "right" }) { () => NAV_GROUPS.filter((g) => { if (!canSeeNavGroup(g.id, role, branchId)) return false; - return g.items.some((item) => canSeeNavItem(item.key, role, branchId)); + return g.items.some((item) => canSeeNavItem(item.key, role, branchId, permissions)); }), - [role, branchId] + [role, branchId, permissions] ); const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => { @@ -332,6 +336,7 @@ export function Sidebar({ side }: { side: "left" | "right" }) { pathname={pathname} role={role} branchId={branchId} + permissions={permissions} tItem={(key) => t(key)} collapsed={collapsed} /> diff --git a/web/dashboard/src/components/layout/topbar.tsx b/web/dashboard/src/components/layout/topbar.tsx index 666d6b7..8e4da40 100644 --- a/web/dashboard/src/components/layout/topbar.tsx +++ b/web/dashboard/src/components/layout/topbar.tsx @@ -14,6 +14,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { HeaderCenterCluster } from "@/components/layout/header-center-cluster"; +import { BranchSwitcher } from "@/components/layout/branch-switcher"; import { NotificationCenter } from "@/components/notifications/notification-center"; import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator"; @@ -62,6 +63,7 @@ export function Topbar() { {/* Actions */}
+ diff --git a/web/dashboard/src/components/pos/pos-pay-panel.tsx b/web/dashboard/src/components/pos/pos-pay-panel.tsx index 5f55028..6a1a8e7 100644 --- a/web/dashboard/src/components/pos/pos-pay-panel.tsx +++ b/web/dashboard/src/components/pos/pos-pay-panel.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import * as signalR from "@microsoft/signalr"; -import { apiGet, apiPatch, apiPost, ApiClientError } from "@/lib/api/client"; +import { apiGet, apiPost, ApiClientError } from "@/lib/api/client"; import { printErrorMessage, printReceipt } from "@/lib/api/print"; import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device"; import { PosSlipModal } from "@/components/pos/pos-slip-modal"; @@ -54,6 +54,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [payMessage, setPayMessage] = useState(null); + const [cancelReason, setCancelReason] = useState(""); const [receiptOrder, setReceiptOrder] = useState(null); const printSettingsBranchId = receiptOrder?.branchId ?? branchId ?? null; const [lastPaidOrderId, setLastPaidOrderId] = useState(null); @@ -167,6 +168,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan useEffect(() => { setLoyaltyRedeem(0); + setCancelReason(""); }, [selected?.id]); useEffect(() => { @@ -246,23 +248,31 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan }); const cancelOrder = useMutation({ - mutationFn: (orderId: string) => - apiPatch(`/api/cafes/${cafeId}/orders/${orderId}/status`, { - status: "Cancelled", + mutationFn: ({ orderId, reason }: { orderId: string; reason: string }) => + apiPost(`/api/cafes/${cafeId}/orders/${orderId}/cancel`, { + reason: reason.trim() || undefined, }), onSuccess: () => { setPayMessage(t("cancelOrderSuccess")); + setCancelReason(""); setSelectedId(null); setSelectedTableId(null); setFilterTableId(null); setPaymentRows([{ method: "Cash", amount: "" }]); queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); + queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] }); queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); }, onError: (err) => { - setPayMessage( - err instanceof ApiClientError ? err.message : t("cancelOrderError") - ); + if (err instanceof ApiClientError) { + if (err.code === "ORDER_HAS_PAYMENTS") { + setPayMessage(t("cancelOrderHasPayments")); + return; + } + setPayMessage(err.message || t("cancelOrderError")); + return; + } + setPayMessage(t("cancelOrderError")); }, }); @@ -596,6 +606,13 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan > {t("previewBill")} + setCancelReason(e.target.value)} + placeholder={t("cancelReasonPlaceholder")} + className="h-9" + maxLength={500} + />