Compare commits

10 Commits

Author SHA1 Message Date
soroush.asadi 345ae0a4b5 first commit
CI/CD / CI · Admin API (dotnet build) (push) Successful in 41s
CI/CD / CI · Admin Web (tsc) (push) Failing after 5s
CI/CD / CI · Website (tsc) (push) Failing after 4s
CI/CD / CI · Koja (tsc) (push) Failing after 5s
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m13s
CI/CD / CI · Dashboard (tsc) (push) Failing after 2m32s
CI/CD / Deploy · all services (push) Has been skipped
2026-05-31 11:06:24 +03:30
soroush.asadi 51e422272d bugfix : remove orphan 2026-05-30 09:42:32 +03:30
soroush.asadi 2850ed8ed7 Align advertised branch limits with backend enforcement
Plan comparison and website pricing advertised branch counts that did not
match PlanLimitsData.ForTier: Pro now shows 3 (was 1) and Business shows
unlimited (was 5), matching what the backend actually enforces.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 05:57:42 +03:30
soroush.asadi 86bbefb9e3 Fix admin-web build: drop invalid --webpack flag on Next.js 14
The admin app runs Next.js 14.2.18, where `next build --webpack` is an
unknown option (the flag only exists in Next 15+). This broke the CI
admin-web image build. Other web apps stay on the flag since they're on
Next 16.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 05:57:31 +03:30
soroush.asadi 8ca2cae988 Pull Docker images from Nexus connector port 8087
The Docker daemon reaches the Nexus Docker group over the dedicated
connector port 8087 (its registry mirror), not the main 8081 HTTP port,
which caused HTTPS-to-HTTP pull failures in CI. Repoint all image refs to
171.22.25.73:8087 at the connector root; npm and NuGet stay on 8081.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 05:17:21 +03:30
soroush.asadi 09c55669ca Add proforma invoice step to subscription checkout
Insert a factor/invoice page between plan selection and payment showing
billing-period choice, line items, and totals before redirecting to the
gateway, moving payment-method selection to where the charge happens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:29:17 +03:30
soroush.asadi 639573dfde Add dashboard chrome to POS and collapsible sidebar
Wrap the POS terminal in the sidebar + topbar layout via a nested
fullscreen layout, and make the sidebar collapse to an icon-only rail
with a persisted toggle so operators keep navigation on the POS screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:28:56 +03:30
soroush.asadi b6e4f83035 Migrate Kavenegar SMS to official .NET SDK
Replace the raw HttpClient implementation with the Kavenegar NuGet SDK
(v1.2.4) for OTP, single, and bulk sends plus account info, wrapping the
synchronous SDK calls and translating its exceptions. Register the
service as scoped instead of via AddHttpClient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:28:28 +03:30
soroush.asadi e8cd6d3282 Route all package mirrors through local Nexus
Point Docker, NuGet, and npm pulls at the Nexus group repos on
171.22.25.73:8081 for both CI/CD and local builds, so the pipeline and
developers no longer depend on Docker Hub, MCR, nuget.org, or npmjs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:28:07 +03:30
soroush.asadi 62bd7a12f5 Build Next.js apps with Webpack instead of Turbopack
Next 16 defaults `next build` to Turbopack, which requires native SWC
bindings unavailable for Alpine musl from our npm mirror (only the WASM
fallback loads). Pass --webpack so the build uses the WASM SWC fallback
and succeeds inside the Docker images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:35:28 +03:30
87 changed files with 12706 additions and 443 deletions
+2 -2
View File
@@ -81,5 +81,5 @@ KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F433346
SNAPPFOOD_WEBHOOK_SECRET=change-me-snappfood-secret SNAPPFOOD_WEBHOOK_SECRET=change-me-snappfood-secret
# ── Docker image overrides (if direct MCR pull fails) ──────────────────────── # ── Docker image overrides (if direct MCR pull fails) ────────────────────────
# DOTNET_SDK_IMAGE=171.22.25.73:5002/dotnet/sdk:10.0 # DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0
# DOTNET_ASPNET_IMAGE=171.22.25.73:5002/dotnet/aspnet:10.0 # DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0
+25 -29
View File
@@ -17,13 +17,12 @@ concurrency:
# ubuntu-latest:docker://node:20-alpine ← CI jobs run in real Docker containers # ubuntu-latest:docker://node:20-alpine ← CI jobs run in real Docker containers
# self-hosted:host ← deploy runs directly on the server # self-hosted:host ← deploy runs directly on the server
# #
# All images are pulled from local Nexus mirrors (fast, no internet): # All images/packages served from Nexus at mirror.soroushasadi.com:
# Docker Hub → http://171.22.25.73:5000 (docker-hub-proxy repo) # Docker images → mirror.soroushasadi.com (docker-group: Docker Hub + MCR)
# MCR → http://171.22.25.73:5002 (mcr-proxy repo) # NuGet → https://mirror.soroushasadi.com/repository/nuget-group/
# npm → https://mirror.soroushasadi.com/repository/npm-group/
# #
# mirror hostname → host-gateway (docker bridge IP 172.17.0.1) — used for: # Docker daemon: merge docker/daemon-registry-mirror.example.json into daemon.json
# NuGet → http://mirror:8081/repository/nuget-group/
# npm → http://mirror:8081/repository/npm-group/
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
jobs: jobs:
@@ -32,13 +31,12 @@ jobs:
name: "CI · API (dotnet build + test)" name: "CI · API (dotnet build + test)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5002/dotnet/sdk:10.0 image: mirror.soroushasadi.com/dotnet/sdk:10.0
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
services: services:
postgres: postgres:
image: docker-mirror.liara.ir/library/postgres:16-alpine image: mirror.soroushasadi.com/postgres:16-alpine
env: env:
POSTGRES_DB: meezi_test POSTGRES_DB: meezi_test
POSTGRES_USER: meezi POSTGRES_USER: meezi
@@ -49,7 +47,7 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
redis: redis:
image: docker-mirror.liara.ir/library/redis:7-alpine image: mirror.soroushasadi.com/redis:7-alpine
options: >- options: >-
--health-cmd "redis-cli ping" --health-cmd "redis-cli ping"
--health-interval 5s --health-interval 5s
@@ -74,8 +72,10 @@ jobs:
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<add key="nexus" value="http://mirror:8081/repository/nuget-group/index.json" <add key="nexus"
protocolVersion="3" allowInsecureConnections="true" /> value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3"
/>
</packageSources> </packageSources>
</configuration> </configuration>
EOF EOF
@@ -98,10 +98,9 @@ jobs:
name: "CI · Admin API (dotnet build)" name: "CI · Admin API (dotnet build)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5002/dotnet/sdk:10.0 image: mirror.soroushasadi.com/dotnet/sdk:10.0
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -121,8 +120,10 @@ jobs:
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<add key="nexus" value="http://mirror:8081/repository/nuget-group/index.json" <add key="nexus"
protocolVersion="3" allowInsecureConnections="true" /> value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3"
/>
</packageSources> </packageSources>
</configuration> </configuration>
EOF EOF
@@ -139,10 +140,9 @@ jobs:
name: "CI · Dashboard (tsc)" name: "CI · Dashboard (tsc)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5000/library/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -158,7 +158,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
working-directory: web/dashboard working-directory: web/dashboard
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
- name: TypeScript check - name: TypeScript check
working-directory: web/dashboard working-directory: web/dashboard
@@ -170,10 +170,9 @@ jobs:
name: "CI · Admin Web (tsc)" name: "CI · Admin Web (tsc)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5000/library/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -189,7 +188,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
working-directory: web/admin working-directory: web/admin
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
- name: TypeScript check - name: TypeScript check
working-directory: web/admin working-directory: web/admin
@@ -201,10 +200,9 @@ jobs:
name: "CI · Website (tsc)" name: "CI · Website (tsc)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5000/library/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -220,7 +218,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
working-directory: web/website working-directory: web/website
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
- name: TypeScript check - name: TypeScript check
working-directory: web/website working-directory: web/website
@@ -232,10 +230,9 @@ jobs:
name: "CI · Koja (tsc)" name: "CI · Koja (tsc)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:5000/library/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
--add-host=mirror:host-gateway
steps: steps:
- name: Checkout - name: Checkout
env: env:
@@ -251,7 +248,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
working-directory: web/koja working-directory: web/koja
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
- name: TypeScript check - name: TypeScript check
working-directory: web/koja working-directory: web/koja
@@ -316,7 +313,6 @@ jobs:
- name: Start main services - name: Start main services
run: | run: |
docker compose up -d \ docker compose up -d \
--remove-orphans \
--no-deps \ --no-deps \
postgres redis api web website koja postgres redis api web website koja
+4 -4
View File
@@ -6,7 +6,7 @@
Server: 171.22.25.73 Server: 171.22.25.73
├── Gitea :3000 ← source control + CI runner ├── 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-api :5080 ← .NET main API
├── meezi-admin-api:5081 ← .NET admin API ├── meezi-admin-api:5081 ← .NET admin API
@@ -128,7 +128,7 @@ CI takes ~510 minutes: builds 6 Docker images, runs all checks, then deploys.
| Main API (Swagger) | http://171.22.25.73:5080/swagger | | Main API (Swagger) | http://171.22.25.73:5080/swagger |
| Admin API (Swagger) | http://171.22.25.73:5081/swagger | | Admin API (Swagger) | http://171.22.25.73:5081/swagger |
| Gitea | http://171.22.25.73:3000 | | 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) # Start (first time or after server reboot)
docker compose -f docker-compose.mirror.yml up -d docker compose -f docker-compose.mirror.yml up -d
# Health check # Health check (on server or via domain)
curl -s http://localhost:8081/service/rest/v1/status curl -s https://mirror.soroushasadi.com/service/rest/v1/status
``` ```
Provisioned repos: Provisioned repos:
+1
View File
@@ -24,6 +24,7 @@
<PackageVersion Include="QuestPDF" Version="2024.12.3" /> <PackageVersion Include="QuestPDF" Version="2024.12.3" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageVersion Include="Kavenegar" Version="1.2.4" />
<PackageVersion Include="StackExchange.Redis" Version="2.8.16" /> <PackageVersion Include="StackExchange.Redis" Version="2.8.16" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" /> <PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" />
+4 -4
View File
@@ -16,8 +16,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr-mirror.liara.ir/dotnet/sdk:10.0} DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mirror.soroushasadi.com/dotnet/sdk:10.0}
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0} DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mirror.soroushasadi.com/dotnet/aspnet:10.0}
container_name: meezi-admin-api container_name: meezi-admin-api
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -52,8 +52,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
NEXT_PUBLIC_ADMIN_API_URL: ${NEXT_PUBLIC_ADMIN_API_URL:-http://localhost:5081} NEXT_PUBLIC_ADMIN_API_URL: ${NEXT_PUBLIC_ADMIN_API_URL:-http://localhost:5081}
container_name: meezi-admin-web container_name: meezi-admin-web
restart: unless-stopped restart: unless-stopped
+4 -4
View File
@@ -6,10 +6,10 @@
# ./mirrors/nexus/provision.sh # creates all proxy repos + enables anon access # ./mirrors/nexus/provision.sh # creates all proxy repos + enables anon access
# #
# Endpoints (after provisioning): # Endpoints (after provisioning):
# UI → http://SERVER_IP:8081 (admin / see provision.sh output) # UI → https://mirror.soroushasadi.com/ (admin / see provision.sh output)
# NuGet → http://SERVER_IP:8081/repository/nuget-proxy/index.json # NuGet → https://mirror.soroushasadi.com/repository/nuget-group/index.json
# npm → http://SERVER_IP:8081/repository/npm-proxy/ # npm → https://mirror.soroushasadi.com/repository/npm-group/
# Docker → http://SERVER_IP:5000 (add to /etc/docker/daemon.json) # 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. # 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. # Adjust INSTALL4J_ADD_VM_PARAMS below if your server has more/less RAM.
+17 -10
View File
@@ -1,5 +1,12 @@
# Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja) # Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja)
# #
# 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: merge docker/daemon-registry-mirror.example.json into daemon.json
#
# Local dev: # Local dev:
# cp .env.example .env # cp .env.example .env
# docker compose up -d --build # docker compose up -d --build
@@ -18,7 +25,7 @@
services: services:
postgres: postgres:
image: ${POSTGRES_IMAGE:-docker-mirror.liara.ir/library/postgres:16-alpine} image: ${POSTGRES_IMAGE:-mirror.soroushasadi.com/postgres:16-alpine}
container_name: meezi-db container_name: meezi-db
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -36,7 +43,7 @@ services:
retries: 10 retries: 10
redis: redis:
image: ${REDIS_IMAGE:-docker-mirror.liara.ir/library/redis:7-alpine} image: ${REDIS_IMAGE:-mirror.soroushasadi.com/redis:7-alpine}
container_name: meezi-redis container_name: meezi-redis
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -57,8 +64,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr-mirror.liara.ir/dotnet/sdk:10.0} DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mirror.soroushasadi.com/dotnet/sdk:10.0}
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0} DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mirror.soroushasadi.com/dotnet/aspnet:10.0}
container_name: meezi-api container_name: meezi-api
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -103,8 +110,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
container_name: meezi-web container_name: meezi-web
restart: unless-stopped restart: unless-stopped
@@ -124,8 +131,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
MEEZI_API_URL: http://api:8080 MEEZI_API_URL: http://api:8080
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
container_name: meezi-website container_name: meezi-website
@@ -148,8 +155,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/} NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103}
container_name: meezi-koja container_name: meezi-koja
+3 -3
View File
@@ -1,11 +1,11 @@
ARG DOTNET_SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0 ARG DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0
ARG DOTNET_ASPNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0 ARG DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0
FROM ${DOTNET_SDK_IMAGE} AS build FROM ${DOTNET_SDK_IMAGE} AS build
WORKDIR /src WORKDIR /src
COPY global.json Directory.Build.props Directory.Packages.props ./ COPY global.json Directory.Build.props Directory.Packages.props ./
# nuget.docker.config points to local Nexus mirror (mirror:8081 via extra_hosts in compose) # nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
COPY nuget.docker.config ./nuget.config COPY nuget.docker.config ./nuget.config
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/ COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
+2 -2
View File
@@ -1,9 +1,9 @@
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
FROM ${NODE_IMAGE} AS deps FROM ${NODE_IMAGE} AS deps
WORKDIR /app WORKDIR /app
COPY web/admin/package*.json ./ COPY web/admin/package*.json ./
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
# Install deps then ensure Alpine (musl) SWC binary is present # Install deps then ensure Alpine (musl) SWC binary is present
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \ 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)") \ && NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
+3 -3
View File
@@ -1,11 +1,11 @@
ARG DOTNET_SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0 ARG DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0
ARG DOTNET_ASPNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0 ARG DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0
FROM ${DOTNET_SDK_IMAGE} AS build FROM ${DOTNET_SDK_IMAGE} AS build
WORKDIR /src WORKDIR /src
COPY global.json Directory.Build.props Directory.Packages.props ./ COPY global.json Directory.Build.props Directory.Packages.props ./
# nuget.docker.config points to local Nexus mirror (mirror:8081 via extra_hosts in compose) # nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
COPY nuget.docker.config ./nuget.config COPY nuget.docker.config ./nuget.config
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/ COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
+1 -2
View File
@@ -1,6 +1,5 @@
{ {
"registry-mirrors": [ "registry-mirrors": [
"https://docker.iranrepo.ir", "https://mirror.soroushasadi.com"
"https://registry.docker.ir"
] ]
} }
+2 -2
View File
@@ -1,9 +1,9 @@
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
FROM ${NODE_IMAGE} AS deps FROM ${NODE_IMAGE} AS deps
WORKDIR /app WORKDIR /app
COPY web/koja/package*.json ./ COPY web/koja/package*.json ./
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
FROM ${NODE_IMAGE} AS builder FROM ${NODE_IMAGE} AS builder
+2 -2
View File
@@ -1,9 +1,9 @@
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
FROM ${NODE_IMAGE} AS deps FROM ${NODE_IMAGE} AS deps
WORKDIR /app WORKDIR /app
COPY web/dashboard/package*.json ./ COPY web/dashboard/package*.json ./
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
FROM ${NODE_IMAGE} AS builder FROM ${NODE_IMAGE} AS builder
+2 -2
View File
@@ -1,9 +1,9 @@
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
FROM ${NODE_IMAGE} AS deps FROM ${NODE_IMAGE} AS deps
WORKDIR /app WORKDIR /app
COPY web/website/package*.json ./ COPY web/website/package*.json ./
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
# Install deps then ensure Alpine (musl) SWC binary is present # Install deps then ensure Alpine (musl) SWC binary is present
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \ 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)") \ && NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
+6 -6
View File
@@ -136,18 +136,18 @@ echo "════════════════════════
echo "🎉 Done!" echo "🎉 Done!"
echo "═══════════════════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════════════════"
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 " Liara first, Runflare as fallback"
echo "" 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 " Liara first, Runflare as fallback"
echo "" 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 " distribution: $UBUNTU_DIST"
echo " security: http://SERVER:8081/repository/ubuntu-security-proxy/" echo " security: https://mirror.soroushasadi.com/repository/ubuntu-security-proxy/"
echo "" echo ""
echo "To use Ubuntu APT in a Dockerfile:" 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 " RUN echo 'deb https://mirror.soroushasadi.com/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 " 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 " apt-get update"
echo "" echo ""
+6 -6
View File
@@ -176,12 +176,12 @@ echo "════════════════════════
echo "🎉 Nexus provisioned!" echo "🎉 Nexus provisioned!"
echo "═══════════════════════════════════════════════════════════════" echo "═══════════════════════════════════════════════════════════════"
echo "" echo ""
echo " UI → http://$(hostname -I | awk '{print $1}'):8081" echo " UI → https://mirror.soroushasadi.com/"
echo " admin / $ADMIN_PASS" echo " admin / $ADMIN_PASS"
echo "" echo ""
echo " NuGet → http://$(hostname -I | awk '{print $1}'):8081/repository/nuget-proxy/index.json" echo " NuGet → https://mirror.soroushasadi.com/repository/nuget-group/index.json"
echo " npm → http://$(hostname -I | awk '{print $1}'):8081/repository/npm-proxy/" echo " npm → https://mirror.soroushasadi.com/repository/npm-group/"
echo " Docker → http://$(hostname -I | awk '{print $1}'):8083 ← upstream: $DOCKER_UPSTREAM" echo " Docker → https://mirror.soroushasadi.com ← upstream: $DOCKER_UPSTREAM"
echo "" echo ""
if [ -z "$DOCKER_USER" ]; then if [ -z "$DOCKER_USER" ]; then
echo " 💡 To switch Docker upstream to Liara mirror (faster in Iran):" echo " 💡 To switch Docker upstream to Liara mirror (faster in Iran):"
@@ -194,7 +194,7 @@ if [ -z "$DOCKER_USER" ]; then
echo "" echo ""
fi fi
echo "To activate Docker Hub mirror on this server:" echo "To activate Docker Hub mirror on this server:"
echo " Edit /etc/docker/daemon.json:" echo " Merge docker/daemon-registry-mirror.example.json into /etc/docker/daemon.json"
echo ' { "insecure-registries": ["'"$(hostname -I | awk '{print $1}'):8083"'"], "registry-mirrors": ["http://'"$(hostname -I | awk '{print $1}'):8083"'"] }' echo ' { "registry-mirrors": ["https://mirror.soroushasadi.com"] }'
echo " systemctl restart docker" echo " systemctl restart docker"
echo "" echo ""
+3 -2
View File
@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- NuGet config for Docker builds — routes restores through Liara NuGet mirror. --> <!-- NuGet config for Docker builds — routes restores through Nexus at mirror.soroushasadi.com -->
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<add key="liara-nuget" value="https://package-mirror.liara.ir/repository/nuget/index.json" <add key="nexus"
value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3" /> protocolVersion="3" />
</packageSources> </packageSources>
<config> <config>
+1 -1
View File
@@ -8,7 +8,7 @@
<clear /> <clear />
<!-- nuget-group aggregates nuget.org-proxy (Liara) + nuget-runflare-proxy (Runflare). <!-- nuget-group aggregates nuget.org-proxy (Liara) + nuget-runflare-proxy (Runflare).
If Liara is down, Nexus automatically falls back to Runflare. --> If Liara is down, Nexus automatically falls back to Runflare. -->
<add key="nexus-nuget" value="http://mirror:8081/repository/nuget-group/index.json" protocolVersion="3" /> <add key="nexus-nuget" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json" protocolVersion="3" />
</packageSources> </packageSources>
<config> <config>
<add key="http_retry_count" value="8" /> <add key="http_retry_count" value="8" />
@@ -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;
/// <summary>
/// Read-only access to the immutable POS / management audit trail. Gated by
/// <see cref="Permission.ViewReports"/>; branch-scoped sessions only ever see
/// their own branch's entries (enforced by the DB-level branch isolation filter),
/// café-wide owners see everything.
/// </summary>
[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<IActionResult> 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<AuditLogDto>(true, items, new PagedMeta(total, page, pageSize)));
}
}
+23 -1
View File
@@ -6,7 +6,6 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Meezi.API.Models.Auth; using Meezi.API.Models.Auth;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.API.Services;
using Meezi.Core.Constants; using Meezi.Core.Constants;
using Meezi.Shared; using Meezi.Shared;
@@ -91,6 +90,27 @@ public class AuthController : ControllerBase
return Ok(new ApiResponse<AuthTokenResponse>(true, data)); return Ok(new ApiResponse<AuthTokenResponse>(true, data));
} }
[HttpPost("switch-branch")]
[Authorize]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> 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<AuthTokenResponse>(true, data));
}
[HttpPost("refresh")] [HttpPost("refresh")]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
@@ -179,6 +199,8 @@ public class AuthController : ControllerBase
new ApiResponse<object>(false, null, new ApiError(code, message))), new ApiResponse<object>(false, null, new ApiError(code, message))),
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))), "NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))), "INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
"BRANCH_FORBIDDEN" => StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError(code, message))),
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))), "ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message))) _ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
}; };
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -27,6 +28,50 @@ public abstract class CafeApiControllerBase : ControllerBase
new ApiError("OWNER_REQUIRED", "Only the cafe owner can perform this action."))); new ApiError("OWNER_REQUIRED", "Only the cafe owner can perform this action.")));
} }
/// <summary>Owner or Manager may act.</summary>
protected IActionResult? EnsureManager(ITenantContext tenant)
{
if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager)
return null;
return Forbidden("MANAGER_REQUIRED", "Manager access required.");
}
/// <summary>The employee acting on their own record, or a manager/owner.</summary>
protected IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant)
{
if (tenant.UserId == employeeId)
return null;
return EnsureManager(tenant);
}
/// <summary>Gate by an explicit capability from the role→permission matrix.</summary>
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.");
}
/// <summary>
/// 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).
/// </summary>
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<object>(false, null, new ApiError(code, message)));
protected static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation) protected static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation)
{ {
var first = validation.Errors.First(); var first = validation.Errors.First();
-17
View File
@@ -201,21 +201,4 @@ public class HrController : CafeApiControllerBase
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data)); return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
} }
private static IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant)
{
if (tenant.UserId == employeeId) return null;
return EnsureManager(tenant);
}
private static IActionResult? EnsureManager(ITenantContext tenant)
{
if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager)
return null;
return new ObjectResult(new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Manager access required.")))
{
StatusCode = StatusCodes.Status403Forbidden
};
}
} }
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Orders; using Meezi.API.Models.Orders;
using Meezi.API.Services; using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Shared; using Meezi.Shared;
@@ -13,6 +14,7 @@ namespace Meezi.API.Controllers;
public class OrdersController : CafeApiControllerBase public class OrdersController : CafeApiControllerBase
{ {
private readonly IOrderService _orderService; private readonly IOrderService _orderService;
private readonly IAuditLogService _audit;
private readonly IValidator<CreateOrderRequest> _createValidator; private readonly IValidator<CreateOrderRequest> _createValidator;
private readonly IValidator<UpdateOrderStatusRequest> _statusValidator; private readonly IValidator<UpdateOrderStatusRequest> _statusValidator;
private readonly IValidator<RecordPaymentsRequest> _paymentsValidator; private readonly IValidator<RecordPaymentsRequest> _paymentsValidator;
@@ -21,6 +23,7 @@ public class OrdersController : CafeApiControllerBase
public OrdersController( public OrdersController(
IOrderService orderService, IOrderService orderService,
IAuditLogService audit,
IValidator<CreateOrderRequest> createValidator, IValidator<CreateOrderRequest> createValidator,
IValidator<UpdateOrderStatusRequest> statusValidator, IValidator<UpdateOrderStatusRequest> statusValidator,
IValidator<RecordPaymentsRequest> paymentsValidator, IValidator<RecordPaymentsRequest> paymentsValidator,
@@ -28,6 +31,7 @@ public class OrdersController : CafeApiControllerBase
IValidator<UpdateOrderSessionRequest> sessionValidator) IValidator<UpdateOrderSessionRequest> sessionValidator)
{ {
_orderService = orderService; _orderService = orderService;
_audit = audit;
_createValidator = createValidator; _createValidator = createValidator;
_statusValidator = statusValidator; _statusValidator = statusValidator;
_paymentsValidator = paymentsValidator; _paymentsValidator = paymentsValidator;
@@ -131,6 +135,16 @@ public class OrdersController : CafeApiControllerBase
if (!result.Success) if (!result.Success)
return OrderError(result.ErrorCode!, result.Field); 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<OrderDto>(true, result.Data)); return Ok(new ApiResponse<OrderDto>(true, result.Data));
} }
@@ -188,6 +202,42 @@ public class OrdersController : CafeApiControllerBase
return Ok(new ApiResponse<OrderDto>(true, data)); return Ok(new ApiResponse<OrderDto>(true, data));
} }
[HttpPost("{id}/cancel")]
public async Task<IActionResult> 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<OrderDto>(true, result.Data));
}
[HttpPost("{id}/payments")] [HttpPost("{id}/payments")]
public async Task<IActionResult> RecordPayments( public async Task<IActionResult> RecordPayments(
string cafeId, string cafeId,
@@ -203,6 +253,23 @@ public class OrdersController : CafeApiControllerBase
var result = await _orderService.RecordPaymentsAsync( var result = await _orderService.RecordPaymentsAsync(
cafeId, id, request, tenant.UserId, cancellationToken); cafeId, id, request, tenant.UserId, cancellationToken);
if (!result.Success) return OrderError(result.ErrorCode!, result.Field); 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<IReadOnlyList<PaymentDto>>(true, result.Data)); return Ok(new ApiResponse<IReadOnlyList<PaymentDto>>(true, result.Data));
} }
@@ -219,6 +286,10 @@ public class OrdersController : CafeApiControllerBase
false, null, new ApiError(code, "Order not found.", field))), false, null, new ApiError(code, "Order not found.", field))),
"ORDER_ALREADY_CLOSED" => BadRequest(new ApiResponse<object>( "ORDER_ALREADY_CLOSED" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Order is already closed.", field))), false, null, new ApiError(code, "Order is already closed.", field))),
"ORDER_ALREADY_CANCELLED" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Order is already cancelled.", field))),
"ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse<object>(
false, null, new ApiError(code, "Refund the recorded payments before cancelling this order.", field))),
"ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>( "ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>(
false, null, new ApiError(code, "Line item not found.", field))), false, null, new ApiError(code, "Line item not found.", field))),
"ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>( "ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>(
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<IActionResult> 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<IReadOnlyList<BranchRoleAssignmentDto>>(true, data));
}
[HttpPost]
public async Task<IActionResult> 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<object>(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<BranchRoleAssignmentDto>(true,
new BranchRoleAssignmentDto(assignment.Id, request.BranchId, branchName, request.Role)));
}
[HttpPatch("{assignmentId}")]
public async Task<IActionResult> 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<BranchRoleAssignmentDto>(true,
new BranchRoleAssignmentDto(assignment.Id, assignment.BranchId, branchName, assignment.Role)));
}
[HttpDelete("{assignmentId}")]
public async Task<IActionResult> 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<object>(true, null));
}
private static ApiResponse<object> Error(string code, string message) =>
new(false, null, new ApiError(code, message));
}
@@ -28,6 +28,7 @@ public static class ServiceCollectionExtensions
services.AddMeeziSecurity(configuration); services.AddMeeziSecurity(configuration);
services.AddInfrastructure(configuration); services.AddInfrastructure(configuration);
services.AddScoped<IAuthService, AuthService>(); services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAuditLogService, AuditLogService>();
services.AddScoped<IConsumerAuthService, ConsumerAuthService>(); services.AddScoped<IConsumerAuthService, ConsumerAuthService>();
services.AddScoped<IConsumerOrdersService, ConsumerOrdersService>(); services.AddScoped<IConsumerOrdersService, ConsumerOrdersService>();
services.AddScoped<IKitchenStationService, KitchenStationService>(); services.AddScoped<IKitchenStationService, KitchenStationService>();
@@ -0,0 +1,16 @@
namespace Meezi.API.Models.Audit;
/// <summary>A single audit-trail entry as exposed to the dashboard.</summary>
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);
+12 -1
View File
@@ -8,6 +8,9 @@ public record RefreshTokenRequest(string RefreshToken);
public record SwitchCafeRequest(string CafeId); public record SwitchCafeRequest(string CafeId);
/// <summary>Switch the active branch within the current café. Null = café-wide (Owner only).</summary>
public record SwitchBranchRequest(string? BranchId);
/// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary> /// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary>
public record RegisterRequest(string Phone, string CafeName); public record RegisterRequest(string Phone, string CafeName);
@@ -17,6 +20,9 @@ public record VerifyRegisterRequest(string Phone, string Code);
/// <summary>One café membership entry returned when user belongs to multiple cafés.</summary> /// <summary>One café membership entry returned when user belongs to multiple cafés.</summary>
public record CafeMembershipDto(string CafeId, string CafeName, string Role, string PlanTier); public record CafeMembershipDto(string CafeId, string CafeName, string Role, string PlanTier);
/// <summary>A branch the signed-in employee may operate as, with their role there.</summary>
public record BranchMembershipDto(string BranchId, string BranchName, string Role);
public record AuthTokenResponse( public record AuthTokenResponse(
string AccessToken, string AccessToken,
string RefreshToken, string RefreshToken,
@@ -28,7 +34,12 @@ public record AuthTokenResponse(
string Language, string Language,
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant, string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant,
string? BranchId = null, string? BranchId = null,
List<CafeMembershipDto>? Memberships = null); List<CafeMembershipDto>? Memberships = null,
string? BranchName = null,
bool IsCafeWide = false,
List<BranchMembershipDto>? Branches = null,
/// <summary>Effective capabilities for the active role — drives client-side page/action gating.</summary>
List<string>? Permissions = null);
public record SendOtpResponse(bool Sent, int ExpiresInSeconds); public record SendOtpResponse(bool Sent, int ExpiresInSeconds);
+2
View File
@@ -62,6 +62,8 @@ public record CreateOrderRequest(
public record UpdateOrderStatusRequest(OrderStatus Status); public record UpdateOrderStatusRequest(OrderStatus Status);
public record CancelOrderRequest(string? Reason);
public record CreatePaymentRequest(PaymentMethod Method, decimal Amount, string? Reference); public record CreatePaymentRequest(PaymentMethod Method, decimal Amount, string? Reference);
public record RecordPaymentsRequest( public record RecordPaymentsRequest(
@@ -0,0 +1,16 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Staff;
/// <summary>A single per-branch role assignment for an employee.</summary>
public record BranchRoleAssignmentDto(
string Id,
string BranchId,
string BranchName,
EmployeeRole Role);
/// <summary>Assign (or move) an employee into a branch with a specific role.</summary>
public record AssignBranchRoleRequest(string BranchId, EmployeeRole Role);
/// <summary>Change the role an employee holds in an existing branch assignment.</summary>
public record UpdateBranchRoleRequest(EmployeeRole Role);
+80
View File
@@ -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;
/// <summary>
/// Persists audit entries on a fresh, isolated <see cref="AppDbContext"/> 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.
/// </summary>
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<AuditLogService> _logger;
public AuditLogService(
ITenantContext tenant,
IServiceScopeFactory scopeFactory,
ILogger<AuditLogService> 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<AppDbContext>();
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);
}
}
}
+150 -10
View File
@@ -1,5 +1,6 @@
using Meezi.API.Models.Auth; using Meezi.API.Models.Auth;
using Meezi.API.Security; using Meezi.API.Security;
using Meezi.Core.Authorization;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums; using Meezi.Core.Enums;
using Meezi.Core.Interfaces; 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())) .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList(); .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); 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())) .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList(); .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); 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())) .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList(); .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); return (true, tokens, null, null);
} }
@@ -341,7 +388,7 @@ public class AuthService : IAuthService
{ {
new(cafe.Id, cafe.Name, owner.Role.ToString(), cafe.PlanTier.ToString()) 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); return (true, tokens, null, null);
} }
@@ -360,9 +407,12 @@ public class AuthService : IAuthService
Core.Entities.Employee employee, Core.Entities.Employee employee,
Core.Entities.Cafe cafe, Core.Entities.Cafe cafe,
List<CafeMembershipDto>? memberships, List<CafeMembershipDto>? memberships,
string? requestedBranchId,
CancellationToken cancellationToken) 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 refreshToken = _jwtTokenService.CreateRefreshToken();
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30); var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
@@ -371,24 +421,114 @@ public class AuthService : IAuthService
new RefreshTokenPayload( new RefreshTokenPayload(
employee.Id, employee.Id,
cafe.Id, cafe.Id,
employee.Role.ToString(), resolution.EffectiveRole.ToString(),
cafe.PlanTier.ToString(), cafe.PlanTier.ToString(),
cafe.PreferredLanguage, cafe.PreferredLanguage,
Meezi.Core.Constants.MeeziActorKinds.Merchant), Meezi.Core.Constants.MeeziActorKinds.Merchant,
resolution.ActiveBranchId),
TimeSpan.FromDays(refreshDays), TimeSpan.FromDays(refreshDays),
cancellationToken); cancellationToken);
var permissions = Meezi.Core.Authorization.RolePermissions
.For(resolution.EffectiveRole)
.Select(p => p.ToString())
.OrderBy(p => p)
.ToList();
return new AuthTokenResponse( return new AuthTokenResponse(
accessToken, accessToken,
refreshToken, refreshToken,
_jwtTokenService.GetAccessTokenExpiry(), _jwtTokenService.GetAccessTokenExpiry(),
employee.Id, employee.Id,
cafe.Id, cafe.Id,
employee.Role.ToString(), resolution.EffectiveRole.ToString(),
cafe.PlanTier.ToString(), cafe.PlanTier.ToString(),
cafe.PreferredLanguage, cafe.PreferredLanguage,
Meezi.Core.Constants.MeeziActorKinds.Merchant, Meezi.Core.Constants.MeeziActorKinds.Merchant,
employee.BranchId, resolution.ActiveBranchId,
memberships); memberships,
resolution.ActiveBranchName,
resolution.IsCafeWide,
resolution.Branches,
permissions);
}
private sealed record BranchResolution(
EmployeeRole EffectiveRole,
string? ActiveBranchId,
string? ActiveBranchName,
bool IsCafeWide,
List<BranchMembershipDto> Branches);
/// <summary>
/// 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 <see cref="EmployeeBranchRole"/> assignments, falling
/// back to the legacy single <see cref="Employee.BranchId"/> pin.
/// </summary>
private async Task<BranchResolution> 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<string, EmployeeRole>();
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);
} }
} }
@@ -0,0 +1,33 @@
namespace Meezi.API.Services;
/// <summary>
/// One sensitive POS / management action to record. Actor and tenant fields are
/// resolved from the current request context when not supplied explicitly.
/// </summary>
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; }
/// <summary>Optional branch override; defaults to the active branch from context.</summary>
public string? BranchId { get; init; }
/// <summary>Optional structured payload — serialized to JSON.</summary>
public object? Details { get; init; }
/// <summary>Optional actor name override (display only).</summary>
public string? ActorName { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public interface IAuditLogService
{
Task LogAsync(AuditEntry entry, CancellationToken ct = default);
}
+8
View File
@@ -20,6 +20,14 @@ public interface IAuthService
string employeeId, string targetCafeId, string employeeId, string targetCafeId,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// Re-issue a token scoped to a different branch within the current café.
/// <paramref name="targetBranchId"/> null means café-wide (Owner only).
/// </summary>
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( Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
RefreshTokenRequest request, RefreshTokenRequest request,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
@@ -6,6 +6,14 @@ namespace Meezi.API.Services;
public interface IJwtTokenService public interface IJwtTokenService
{ {
string CreateAccessToken(Employee employee, Cafe cafe); string CreateAccessToken(Employee employee, Cafe cafe);
/// <summary>
/// Issue a token scoped to an active branch. The <paramref name="effectiveRole"/>
/// is the role the employee holds in <paramref name="activeBranchId"/> (or their
/// café-wide role when <paramref name="activeBranchId"/> is null).
/// </summary>
string CreateAccessToken(Employee employee, Cafe cafe, EmployeeRole effectiveRole, string? activeBranchId);
string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa"); string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa");
string CreateRefreshToken(); string CreateRefreshToken();
DateTime GetAccessTokenExpiry(); DateTime GetAccessTokenExpiry();
+8 -4
View File
@@ -3,6 +3,7 @@ using System.Security.Claims;
using System.Text; using System.Text;
using Meezi.Core.Constants; using Meezi.Core.Constants;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Enums;
using ConsumerAccount = Meezi.Core.Entities.ConsumerAccount; using ConsumerAccount = Meezi.Core.Entities.ConsumerAccount;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -17,7 +18,10 @@ public class JwtTokenService : IJwtTokenService
_configuration = configuration; _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 key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
var issuer = _configuration["Jwt:Issuer"] ?? "meezi"; var issuer = _configuration["Jwt:Issuer"] ?? "meezi";
@@ -28,14 +32,14 @@ public class JwtTokenService : IJwtTokenService
{ {
new(JwtRegisteredClaimNames.Sub, employee.Id), new(JwtRegisteredClaimNames.Sub, employee.Id),
new(MeeziClaimTypes.CafeId, cafe.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.PlanTier, cafe.PlanTier.ToString()),
new(MeeziClaimTypes.Language, cafe.PreferredLanguage), new(MeeziClaimTypes.Language, cafe.PreferredLanguage),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")) new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
}; };
if (!string.IsNullOrEmpty(employee.BranchId)) if (!string.IsNullOrEmpty(activeBranchId))
claims.Add(new Claim(MeeziClaimTypes.BranchId, employee.BranchId)); claims.Add(new Claim(MeeziClaimTypes.BranchId, activeBranchId));
var credentials = new SigningCredentials( var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
+53
View File
@@ -55,6 +55,12 @@ public interface IOrderService
string targetTableId, string targetTableId,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<OrderDto?> UpdateStatusAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default); Task<OrderDto?> UpdateStatusAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default);
Task<OrderServiceResult<OrderDto>> CancelOrderAsync(
string cafeId,
string orderId,
string? reason,
string? cancelledByEmployeeId,
CancellationToken cancellationToken = default);
Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> RecordPaymentsAsync( Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> RecordPaymentsAsync(
string cafeId, string cafeId,
string orderId, string orderId,
@@ -957,6 +963,53 @@ public class OrderService : IOrderService
return await GetOrderAsync(cafeId, orderId, cancellationToken); return await GetOrderAsync(cafeId, orderId, cancellationToken);
} }
public async Task<OrderServiceResult<OrderDto>> 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<OrderDto>(false, null, "ORDER_NOT_FOUND");
if (order.Status == OrderStatus.Cancelled)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_ALREADY_CANCELLED");
if (!OpenForPaymentStatuses.Contains(order.Status))
return new OrderServiceResult<OrderDto>(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<OrderDto>(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<OrderDto>(false, null, "ORDER_NOT_FOUND")
: new OrderServiceResult<OrderDto>(true, MapOrder(loaded));
}
public async Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> RecordPaymentsAsync( public async Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> RecordPaymentsAsync(
string cafeId, string cafeId,
string orderId, string orderId,
+2 -1
View File
@@ -9,7 +9,8 @@ public record RefreshTokenPayload(
string Role, string Role,
string PlanTier, string PlanTier,
string Language, string Language,
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant); string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant,
string? ActiveBranchId = null);
public interface IRefreshTokenStore public interface IRefreshTokenStore
{ {
@@ -0,0 +1,41 @@
namespace Meezi.Core.Authorization;
/// <summary>
/// Capabilities a café employee can be granted. These are the single source of
/// truth for authorization — controllers check a <see cref="Permission"/> rather
/// than hard-coding role names, so the role→capability mapping lives in exactly
/// one place (<see cref="RolePermissions"/>).
/// </summary>
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,
}
@@ -0,0 +1,76 @@
using Meezi.Core.Enums;
namespace Meezi.Core.Authorization;
/// <summary>
/// The authoritative role→capability matrix. Change what a role can do here and
/// every controller that calls <c>EnsurePermission</c> updates automatically.
/// </summary>
public static class RolePermissions
{
private static readonly IReadOnlyDictionary<EmployeeRole, HashSet<Permission>> Matrix =
new Dictionary<EmployeeRole, HashSet<Permission>>
{
[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<Permission> For(EmployeeRole role) =>
Matrix.TryGetValue(role, out var set) ? set : new HashSet<Permission>();
/// <summary>True for roles that administer the whole café across all branches.</summary>
public static bool IsCafeWide(EmployeeRole role) => role == EmployeeRole.Owner;
private static HashSet<Permission> AllPermissions() =>
new(Enum.GetValues<Permission>());
}
+34
View File
@@ -0,0 +1,34 @@
namespace Meezi.Core.Entities;
/// <summary>
/// Immutable record of a sensitive POS / management action. Written by
/// <c>IAuditLogService</c> and never updated. Branch-scoped so the strict
/// branch isolation filter applies (café-wide sessions see all).
/// </summary>
public class AuditLog : TenantEntity
{
/// <summary>High-level grouping, e.g. "Order", "Payment", "Register", "Staff".</summary>
public string Category { get; set; } = string.Empty;
/// <summary>Specific action, e.g. "OrderCancelled", "ItemVoided", "PaymentRecorded".</summary>
public string Action { get; set; } = string.Empty;
/// <summary>The entity acted upon, e.g. "Order", "Shift".</summary>
public string? EntityType { get; set; }
/// <summary>Id of the affected entity.</summary>
public string? EntityId { get; set; }
public string? BranchId { get; set; }
/// <summary>Employee who performed the action (null for system/automated).</summary>
public string? ActorId { get; set; }
public string? ActorName { get; set; }
public string? ActorRole { get; set; }
/// <summary>Human-readable one-line summary (already localized at write time or neutral).</summary>
public string Summary { get; set; } = string.Empty;
/// <summary>Optional structured payload (before/after, amounts, reason) as JSON.</summary>
public string? DetailsJson { get; set; }
}
+3
View File
@@ -39,4 +39,7 @@ public class Branch : TenantEntity
public ICollection<Table> Tables { get; set; } = []; public ICollection<Table> Tables { get; set; } = [];
public ICollection<Order> Orders { get; set; } = []; public ICollection<Order> Orders { get; set; } = [];
public ICollection<Employee> Staff { get; set; } = []; public ICollection<Employee> Staff { get; set; } = [];
/// <summary>Per-branch role assignments scoped to this branch.</summary>
public ICollection<EmployeeBranchRole> StaffRoles { get; set; } = [];
} }
+3
View File
@@ -19,4 +19,7 @@ public class Employee : TenantEntity
public ICollection<Attendance> Attendances { get; set; } = []; public ICollection<Attendance> Attendances { get; set; } = [];
public ICollection<EmployeeSchedule> Schedules { get; set; } = []; public ICollection<EmployeeSchedule> Schedules { get; set; } = [];
public ICollection<LeaveRequest> LeaveRequests { get; set; } = []; public ICollection<LeaveRequest> LeaveRequests { get; set; } = [];
/// <summary>Per-branch role assignments (multi-branch staff). Owners are café-wide and may have none.</summary>
public ICollection<EmployeeBranchRole> BranchRoles { get; set; } = [];
} }
@@ -0,0 +1,19 @@
using Meezi.Core.Enums;
namespace Meezi.Core.Entities;
/// <summary>
/// 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
/// <see cref="EmployeeRole"/> in each branch they work at.
/// Owners remain café-wide via <see cref="Employee.Role"/> and need no rows here.
/// </summary>
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!;
}
+6
View File
@@ -34,6 +34,12 @@ public class Order : TenantEntity
/// <summary>JSON snapshot: driver, address, delivery ETA, etc.</summary> /// <summary>JSON snapshot: driver, address, delivery ETA, etc.</summary>
public string? DeliveryMetaJson { get; set; } public string? DeliveryMetaJson { get; set; }
/// <summary>Reason captured when the order was cancelled (POS audit / accountability).</summary>
public string? CancelReason { get; set; }
/// <summary>Employee who cancelled the order (null for system/automated).</summary>
public string? CancelledByEmployeeId { get; set; }
public DateTime? CancelledAt { get; set; }
public Cafe Cafe { get; set; } = null!; public Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; } public Branch? Branch { get; set; }
public Table? Table { get; set; } public Table? Table { get; set; }
+61 -11
View File
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Meezi.Core.Entities; using Meezi.Core.Entities;
using Meezi.Core.Interfaces;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -8,8 +9,22 @@ namespace Meezi.Infrastructure.Data;
public class AppDbContext : DbContext public class AppDbContext : DbContext
{ {
public AppDbContext(DbContextOptions<AppDbContext> 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<AppDbContext> options, IBranchContext? branch = null)
: base(options)
{ {
if (branch is { HasBranch: true })
{
_branchScopeId = branch.BranchId;
_branchScoped = true;
}
} }
public DbSet<Cafe> Cafes => Set<Cafe>(); public DbSet<Cafe> Cafes => Set<Cafe>();
@@ -17,6 +32,7 @@ public class AppDbContext : DbContext
public DbSet<Table> Tables => Set<Table>(); public DbSet<Table> Tables => Set<Table>();
public DbSet<TableSection> TableSections => Set<TableSection>(); public DbSet<TableSection> TableSections => Set<TableSection>();
public DbSet<Employee> Employees => Set<Employee>(); public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeBranchRole> EmployeeBranchRoles => Set<EmployeeBranchRole>();
public DbSet<MenuCategory> MenuCategories => Set<MenuCategory>(); public DbSet<MenuCategory> MenuCategories => Set<MenuCategory>();
public DbSet<MenuItem> MenuItems => Set<MenuItem>(); public DbSet<MenuItem> MenuItems => Set<MenuItem>();
public DbSet<BranchMenuItemOverride> BranchMenuItemOverrides => Set<BranchMenuItemOverride>(); public DbSet<BranchMenuItemOverride> BranchMenuItemOverrides => Set<BranchMenuItemOverride>();
@@ -63,6 +79,9 @@ public class AppDbContext : DbContext
// Push notifications (Pushe) // Push notifications (Pushe)
public DbSet<PushDevice> PushDevices => Set<PushDevice>(); public DbSet<PushDevice> PushDevices => Set<PushDevice>();
// Immutable audit trail of sensitive POS / management actions.
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@@ -120,7 +139,7 @@ public class AppDbContext : DbContext
e.HasIndex(x => new { x.BranchId, x.Name }); e.HasIndex(x => new { x.BranchId, x.Name });
e.HasIndex(x => x.CafeId); e.HasIndex(x => x.CafeId);
e.HasOne(x => x.Branch).WithMany(b => b.Sections).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); 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<Table>(e => modelBuilder.Entity<Table>(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.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.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.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<Employee>(e => modelBuilder.Entity<Employee>(e =>
@@ -149,6 +168,37 @@ public class AppDbContext : DbContext
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null);
}); });
modelBuilder.Entity<EmployeeBranchRole>(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<AuditLog>(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<Cafe>().WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
});
modelBuilder.Entity<MenuCategory>(e => modelBuilder.Entity<MenuCategory>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
@@ -180,7 +230,7 @@ public class AppDbContext : DbContext
e.HasIndex(x => x.CafeId); e.HasIndex(x => x.CafeId);
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); 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.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<Order>(e => modelBuilder.Entity<Order>(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.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.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.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<OrderItem>(e => modelBuilder.Entity<OrderItem>(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.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.OpenedBy).WithMany().HasForeignKey(x => x.OpenedByUserId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.ClosedBy).WithMany().HasForeignKey(x => x.ClosedByUserId).OnDelete(DeleteBehavior.SetNull); 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<CashTransaction>(e => modelBuilder.Entity<CashTransaction>(e =>
@@ -298,7 +348,7 @@ public class AppDbContext : DbContext
e.HasIndex(x => new { x.CafeId, x.BranchId }); 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.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.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<LeaveRequest>(e => modelBuilder.Entity<LeaveRequest>(e =>
@@ -353,7 +403,7 @@ public class AppDbContext : DbContext
e.HasIndex(x => new { x.CafeId, x.SortOrder }); 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.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.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<SubscriptionPayment>(e => modelBuilder.Entity<SubscriptionPayment>(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.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.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(x => x.Order).WithMany().HasForeignKey(x => x.OrderId).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<Expense>(e => modelBuilder.Entity<Expense>(e =>
@@ -426,7 +476,7 @@ public class AppDbContext : DbContext
e.HasIndex(x => new { x.CafeId, x.BranchId, x.CreatedAt }); 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.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.Shift).WithMany().HasForeignKey(x => x.ShiftId).OnDelete(DeleteBehavior.SetNull); 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<DailyReport>(e => modelBuilder.Entity<DailyReport>(e =>
@@ -457,7 +507,7 @@ public class AppDbContext : DbContext
.HasConversion(topProductsConverter, topProductsComparer) .HasConversion(topProductsConverter, topProductsComparer)
.HasColumnType("jsonb"); .HasColumnType("jsonb");
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade); 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<WebhookLog>(e => modelBuilder.Entity<WebhookLog>(e =>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddEmployeeBranchRole : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EmployeeBranchRoles",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
EmployeeId = table.Column<string>(type: "text", nullable: false),
BranchId = table.Column<string>(type: "text", nullable: false),
Role = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CafeId = table.Column<string>(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;
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmployeeBranchRoles");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddAuditLog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AuditLogs",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Category = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Action = table.Column<string>(type: "character varying(96)", maxLength: 96, nullable: false),
EntityType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
EntityId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
BranchId = table.Column<string>(type: "text", nullable: true),
ActorId = table.Column<string>(type: "text", nullable: true),
ActorName = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
ActorRole = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
Summary = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
DetailsJson = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CafeId = table.Column<string>(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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuditLogs");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddOrderCancellationFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CancelReason",
table: "Orders",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CancelledAt",
table: "Orders",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CancelledByEmployeeId",
table: "Orders",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CancelReason",
table: "Orders");
migrationBuilder.DropColumn(
name: "CancelledAt",
table: "Orders");
migrationBuilder.DropColumn(
name: "CancelledByEmployeeId",
table: "Orders");
}
}
}
@@ -57,6 +57,72 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("Attendances"); b.ToTable("Attendances");
}); });
modelBuilder.Entity("Meezi.Core.Entities.AuditLog", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("character varying(96)");
b.Property<string>("ActorId")
.HasColumnType("text");
b.Property<string>("ActorName")
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("ActorRole")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BranchId")
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DetailsJson")
.HasColumnType("text");
b.Property<string>("EntityId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("EntityType")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("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 => modelBuilder.Entity("Meezi.Core.Entities.Branch", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -884,6 +950,45 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("Employees"); b.ToTable("Employees");
}); });
modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("BranchId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CafeId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EmployeeId")
.IsRequired()
.HasColumnType("text");
b.Property<int>("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 => modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -1317,6 +1422,15 @@ namespace Meezi.Infrastructure.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("CancelReason")
.HasColumnType("text");
b.Property<DateTime?>("CancelledAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancelledByEmployeeId")
.HasColumnType("text");
b.Property<string>("CouponId") b.Property<string>("CouponId")
.HasColumnType("text"); .HasColumnType("text");
@@ -2424,6 +2538,15 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Employee"); 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 => modelBuilder.Entity("Meezi.Core.Entities.Branch", b =>
{ {
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
@@ -2565,6 +2688,25 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Cafe"); 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 => modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b =>
{ {
b.HasOne("Meezi.Core.Entities.Employee", "Employee") b.HasOne("Meezi.Core.Entities.Employee", "Employee")
@@ -3012,6 +3154,8 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Navigation("Staff"); b.Navigation("Staff");
b.Navigation("StaffRoles");
b.Navigation("Tables"); b.Navigation("Tables");
}); });
@@ -3061,6 +3205,8 @@ namespace Meezi.Infrastructure.Data.Migrations
{ {
b.Navigation("Attendances"); b.Navigation("Attendances");
b.Navigation("BranchRoles");
b.Navigation("LeaveRequests"); b.Navigation("LeaveRequests");
b.Navigation("Orders"); b.Navigation("Orders");
@@ -29,7 +29,7 @@ public static class DependencyInjection
services.AddScoped<IPlatformCatalogService, PlatformCatalogService>(); services.AddScoped<IPlatformCatalogService, PlatformCatalogService>();
services.AddScoped<ISupportTicketService, SupportTicketService>(); services.AddScoped<ISupportTicketService, SupportTicketService>();
services.AddHttpClient<ISmsService, KavenegarSmsService>(); services.AddScoped<ISmsService, KavenegarSmsService>();
services.AddHttpClient<IZarinPalGateway, ZarinPalGateway>(); services.AddHttpClient<IZarinPalGateway, ZarinPalGateway>();
services.AddHttpClient<ISnappPayGateway, SnappPayGateway>(); services.AddHttpClient<ISnappPayGateway, SnappPayGateway>();
services.AddHttpClient<ITaraPaymentGateway, TaraPaymentGateway>(); services.AddHttpClient<ITaraPaymentGateway, TaraPaymentGateway>();
@@ -1,5 +1,5 @@
using System.Net.Http.Json; using Kavenegar;
using System.Text.Json.Serialization; using Kavenegar.Exceptions;
using Meezi.Core.Interfaces; using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform; using Meezi.Infrastructure.Services.Platform;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging;
namespace Meezi.Infrastructure.ExternalServices; namespace Meezi.Infrastructure.ExternalServices;
/// <summary> /// <summary>
/// Kavenegar SMS gateway implementation. /// Kavenegar SMS gateway implementation using the official Kavenegar .NET SDK.
/// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back /// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back
/// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.). /// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.).
/// </summary> /// </summary>
@@ -21,23 +21,19 @@ public class KavenegarSmsService : ISmsService
private const string DbKeySender = "integrations.kavenegar.senderNumber"; private const string DbKeySender = "integrations.kavenegar.senderNumber";
private const string DbKeyOtpTemplate = "integrations.kavenegar.otpTemplate"; private const string DbKeyOtpTemplate = "integrations.kavenegar.otpTemplate";
private const string BaseUrl = "https://api.kavenegar.com/v1";
private const int MaxBatchSize = 200; private const int MaxBatchSize = 200;
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IPlatformRuntimeConfig _platform; private readonly IPlatformRuntimeConfig _platform;
private readonly IHostEnvironment _environment; private readonly IHostEnvironment _environment;
private readonly ILogger<KavenegarSmsService> _logger; private readonly ILogger<KavenegarSmsService> _logger;
public KavenegarSmsService( public KavenegarSmsService(
HttpClient httpClient,
IConfiguration configuration, IConfiguration configuration,
IPlatformRuntimeConfig platform, IPlatformRuntimeConfig platform,
IHostEnvironment environment, IHostEnvironment environment,
ILogger<KavenegarSmsService> logger) ILogger<KavenegarSmsService> logger)
{ {
_httpClient = httpClient;
_configuration = configuration; _configuration = configuration;
_platform = platform; _platform = platform;
_environment = environment; _environment = environment;
@@ -61,16 +57,11 @@ public class KavenegarSmsService : ISmsService
return; return;
} }
var url = $"{BaseUrl}/{apiKey}/verify/lookup.json"; var receptor = NormalizePhone(phone);
var content = new FormUrlEncodedContent(new Dictionary<string, string> await RunSdkAsync(apiKey, api =>
{ {
["receptor"] = NormalizePhone(phone), api.VerifyLookup(receptor, otp, null, null, template);
["token"] = otp, }, "OTP");
["template"] = template,
});
var response = await _httpClient.PostAsync(url, content, cancellationToken);
await EnsureKavenegarSuccessAsync(response, "OTP", cancellationToken);
} }
public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default) public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default)
@@ -82,11 +73,11 @@ public class KavenegarSmsService : ISmsService
return; return;
} }
var url = $"{BaseUrl}/{apiKey}/sms/send.json"; var receptor = NormalizePhone(phone);
var content = BuildSendForm(phone, message, sender); await RunSdkAsync(apiKey, api =>
{
var response = await _httpClient.PostAsync(url, content, cancellationToken); api.Send(sender, receptor, message);
await EnsureKavenegarSuccessAsync(response, "Send", cancellationToken); }, "Send");
} }
public async Task<BulkSendResult> SendBulkAsync( public async Task<BulkSendResult> SendBulkAsync(
@@ -103,17 +94,18 @@ public class KavenegarSmsService : ISmsService
return new BulkSendResult(0, phones.Count); return new BulkSendResult(0, phones.Count);
} }
var url = $"{BaseUrl}/{apiKey}/sms/send.json";
int sent = 0, failed = 0; int sent = 0, failed = 0;
foreach (var batch in phones.Chunk(MaxBatchSize)) foreach (var batch in phones.Chunk(MaxBatchSize))
{ {
try try
{ {
// Kavenegar /sms/send.json accepts comma-separated receptors var receptors = batch.Select(NormalizePhone).ToList();
var content = BuildSendForm(string.Join(",", batch), message, sender); await RunSdkAsync(apiKey, api =>
var response = await _httpClient.PostAsync(url, content, cancellationToken); {
await EnsureKavenegarSuccessAsync(response, "BulkSend", cancellationToken); api.Send(sender, receptors, message);
}, "BulkSend");
sent += batch.Length; sent += batch.Length;
_logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length); _logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length);
} }
@@ -134,20 +126,12 @@ public class KavenegarSmsService : ISmsService
try try
{ {
var url = $"{BaseUrl}/{apiKey}/account/info.json"; return await Task.Run(() =>
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{ {
_logger.LogWarning("Kavenegar account info returned HTTP {Status}", response.StatusCode); var api = new KavenegarApi(apiKey);
return null; var info = api.AccountInfo();
} return new KavenegarAccountInfo(info.RemainCredit, info.Type ?? "master");
}, cancellationToken);
var body = await response.Content.ReadFromJsonAsync<KavenegarAccountInfoResponse>(cancellationToken: cancellationToken);
if (body?.Return?.Status is not 200 || body.Entries is null)
return null;
return new KavenegarAccountInfo(body.Entries.RemainCredit, body.Entries.Type ?? "master");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -156,42 +140,42 @@ public class KavenegarSmsService : ISmsService
} }
} }
// ── SDK runner ────────────────────────────────────────────────────────────
/// <summary>
/// Runs a synchronous Kavenegar SDK call on the thread pool.
/// Translates SDK exceptions to logged InvalidOperationException.
/// </summary>
private async Task RunSdkAsync(string apiKey, Action<KavenegarApi> action, string operation)
{
await Task.Run(() =>
{
try
{
var api = new KavenegarApi(apiKey);
action(api);
}
catch (ApiException ex)
{
_logger.LogWarning(
"Kavenegar {Op} API error {Code}: {Message}",
operation, ex.Code, ex.Message);
throw new InvalidOperationException(
$"Kavenegar {operation} failed (code {ex.Code}): {ex.Message}", ex);
}
catch (HttpException ex)
{
_logger.LogWarning(
"Kavenegar {Op} HTTP error {Code}: {Message}",
operation, ex.Code, ex.Message);
throw new InvalidOperationException(
$"Kavenegar {operation} HTTP error (code {ex.Code}): {ex.Message}", ex);
}
});
}
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
private static FormUrlEncodedContent BuildSendForm(string receptor, string message, string sender)
{
var dict = new Dictionary<string, string>
{
["receptor"] = receptor,
["message"] = message,
};
if (!string.IsNullOrWhiteSpace(sender))
dict["sender"] = sender;
return new FormUrlEncodedContent(dict);
}
private async Task EnsureKavenegarSuccessAsync(
HttpResponseMessage response,
string operation,
CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
{
var errorCode = (int)response.StatusCode;
var detail = KavenegarHttpError(errorCode);
_logger.LogWarning("Kavenegar {Op} HTTP {Code}: {Detail}", operation, errorCode, detail);
throw new InvalidOperationException($"Kavenegar {operation} failed (HTTP {errorCode}): {detail}");
}
var body = await response.Content.ReadFromJsonAsync<KavenegarReturnEnvelope>(cancellationToken: cancellationToken);
if (body?.Return?.Status is not 200)
{
var status = body?.Return?.Status ?? -1;
_logger.LogWarning("Kavenegar {Op} returned status {Status}: {Message}", operation, status, body?.Return?.Message);
throw new InvalidOperationException($"Kavenegar {operation} failed (status {status}): {body?.Return?.Message}");
}
}
// Strip leading 0 from Iranian mobile numbers (09xxxxxxxxx → 9xxxxxxxxx) // Strip leading 0 from Iranian mobile numbers (09xxxxxxxxx → 9xxxxxxxxx)
private static string NormalizePhone(string phone) private static string NormalizePhone(string phone)
{ {
@@ -200,35 +184,6 @@ public class KavenegarSmsService : ISmsService
return p; return p;
} }
private static string KavenegarHttpError(int code) => code switch
{
400 => "Missing or invalid parameters",
401 => "Account is inactive",
403 => "Invalid API key",
404 => "Method not found",
405 => "Wrong HTTP method",
406 => "Recipient is on the blacklist or number is deactivated",
411 => "Invalid recipient number",
412 => "Invalid sender number",
413 => "Message empty or too long",
414 => "Too many recipients",
415 => "Server error on Kavenegar side",
416 => "Recipient is invalid, blacklisted, or deactivated",
417 => "Invalid scheduled date",
418 => "Insufficient credit",
419 => "OTP token already used or expired",
420 => "IP not allowed",
421 => "Message could not be sent",
422 => "Invalid characters in message",
423 => "Kavenegar server unreachable",
424 => "OTP template not found — check template name in Kavenegar panel",
426 => "IP is not whitelisted",
428 => "Voice call requires numeric token",
431 => "SMS sending is disabled on this account",
432 => "Code parameter missing in OTP template",
_ => $"Undocumented Kavenegar error {code}"
};
private async Task<(string? ApiKey, string Sender, string OtpTemplate)> GetConfigAsync(CancellationToken ct) private async Task<(string? ApiKey, string Sender, string OtpTemplate)> GetConfigAsync(CancellationToken ct)
{ {
var enabled = await _platform.GetAsync(DbKeyEnabled, ct); var enabled = await _platform.GetAsync(DbKeyEnabled, ct);
@@ -250,42 +205,4 @@ public class KavenegarSmsService : ISmsService
return (apiKey, sender, template); return (apiKey, sender, template);
} }
// ── Response models ───────────────────────────────────────────────────────
private sealed class KavenegarReturnEnvelope
{
[JsonPropertyName("return")]
public KavenegarReturn? Return { get; set; }
}
private sealed class KavenegarReturn
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("message")]
public string? Message { get; set; }
}
private sealed class KavenegarAccountInfoResponse
{
[JsonPropertyName("return")]
public KavenegarReturn? Return { get; set; }
[JsonPropertyName("entries")]
public KavenegarAccountEntries? Entries { get; set; }
}
private sealed class KavenegarAccountEntries
{
[JsonPropertyName("remaincredit")]
public long RemainCredit { get; set; }
[JsonPropertyName("expiredate")]
public long ExpireDate { get; set; }
[JsonPropertyName("type")]
public string? Type { get; set; }
}
} }
@@ -14,6 +14,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Kavenegar" />
<PackageReference Include="System.Security.Cryptography.Xml" /> <PackageReference Include="System.Security.Cryptography.Xml" />
</ItemGroup> </ItemGroup>
+51 -2
View File
@@ -56,8 +56,33 @@
"delivery": "عامل التوصيل", "delivery": "عامل التوصيل",
"unknown": "مستخدم" "unknown": "مستخدم"
}, },
"branchSwitcher": {
"title": "الفرع النشط",
"allBranches": "كل الفروع",
"selectBranch": "اختر الفرع"
},
"branchAccess": {
"title": "صلاحيات الفروع",
"staff": "الموظفون",
"noStaff": "لا يوجد موظفون بعد",
"selectStaff": "اختر موظفًا لإدارة الصلاحيات",
"ownerNote": "المالك لديه صلاحية الوصول لكل الفروع ولا يحتاج إلى أدوار خاصة بكل فرع.",
"noAssignments": "لم يتم تعيين أي دور للفروع بعد",
"loading": "جارٍ التحميل...",
"branch": "الفرع",
"role": "الدور",
"selectBranch": "اختر الفرع",
"add": "إضافة",
"remove": "حذف"
},
"access": {
"deniedTitle": "لا تملك صلاحية الوصول إلى هذه الصفحة",
"deniedBody": "دورك لا يملك صلاحية عرض هذه الصفحة. تواصل مع المدير أو المالك إذا كنت بحاجة إلى الوصول."
},
"nav": { "nav": {
"aria": "القائمة الرئيسية", "aria": "القائمة الرئيسية",
"collapseSidebar": "طي الشريط الجانبي",
"expandSidebar": "توسيع الشريط الجانبي",
"groups": { "groups": {
"operations": "العمليات اليومية", "operations": "العمليات اليومية",
"menuSales": "القائمة والمبيعات", "menuSales": "القائمة والمبيعات",
@@ -161,6 +186,8 @@
"cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.", "cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.",
"cancelOrderSuccess": "تم إلغاء الطلب", "cancelOrderSuccess": "تم إلغاء الطلب",
"cancelOrderError": "تعذّر إلغاء الطلب", "cancelOrderError": "تعذّر إلغاء الطلب",
"cancelReasonPlaceholder": "سبب الإلغاء (اختياري)",
"cancelOrderHasPayments": "استرجع المدفوعات المسجّلة أولاً ثم ألغِ الطلب",
"itemsCount": "صنف", "itemsCount": "صنف",
"applyCoupon": "تطبيق القسيمة", "applyCoupon": "تطبيق القسيمة",
"couponPlaceholder": "رمز القسيمة", "couponPlaceholder": "رمز القسيمة",
@@ -358,7 +385,8 @@
"tabs": { "tabs": {
"attendance": "الحضور", "attendance": "الحضور",
"leave": "الإجازة", "leave": "الإجازة",
"payroll": "الرواتب" "payroll": "الرواتب",
"access": "صلاحيات الفروع"
}, },
"myAttendance": "حضوري", "myAttendance": "حضوري",
"clockIn": "تسجيل دخول", "clockIn": "تسجيل دخول",
@@ -945,7 +973,28 @@
"featureDiscover": "ملف الاكتشاف (ذكاء اصطناعي)", "featureDiscover": "ملف الاكتشاف (ذكاء اصطناعي)",
"featureOn": "مفعّل", "featureOn": "مفعّل",
"featureOff": "غير متاح — ترقية", "featureOff": "غير متاح — ترقية",
"featureMenu3dUpgrade": "القائمة 3D متاحة في برو وما فوق." "featureMenu3dUpgrade": "القائمة 3D متاحة في برو وما فوق.",
"checkout": {
"title": "الفاتورة والدفع",
"subtitle": "راجع طلبك وادفع",
"backToPlans": "العودة إلى الخطط",
"invalidPlan": "الخطة المحددة غير متاحة للشراء عبر الإنترنت.",
"invoiceLabel": "فاتورة مبدئية",
"invoiceNo": "رقم الفاتورة",
"issuedAt": "تاريخ الإصدار",
"billingPeriod": "مدة الاشتراك",
"monthsCount": "{count} شهر",
"description": "الوصف",
"qty": "الكمية",
"unitPrice": "سعر الوحدة",
"amount": "المبلغ",
"planLine": "اشتراك خطة {plan}",
"subtotal": "المجموع الفرعي",
"total": "المبلغ المستحق",
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
"payTotal": "ادفع {total}",
"redirecting": "جارٍ التحويل إلى البوابة..."
}
}, },
"settings": { "settings": {
"title": "الإعدادات", "title": "الإعدادات",
+51 -2
View File
@@ -67,8 +67,33 @@
"delivery": "Delivery", "delivery": "Delivery",
"unknown": "User" "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": { "nav": {
"aria": "Main navigation", "aria": "Main navigation",
"collapseSidebar": "Collapse sidebar",
"expandSidebar": "Expand sidebar",
"groups": { "groups": {
"operations": "Daily operations", "operations": "Daily operations",
"menuSales": "Menu & sales", "menuSales": "Menu & sales",
@@ -180,6 +205,8 @@
"cancelOrderConfirm": "Customer left without paying? The order will be cancelled and the table freed.", "cancelOrderConfirm": "Customer left without paying? The order will be cancelled and the table freed.",
"cancelOrderSuccess": "Order cancelled", "cancelOrderSuccess": "Order cancelled",
"cancelOrderError": "Could not cancel order", "cancelOrderError": "Could not cancel order",
"cancelReasonPlaceholder": "Cancellation reason (optional)",
"cancelOrderHasPayments": "Refund the recorded payments first, then cancel the order",
"itemsCount": "items", "itemsCount": "items",
"applyCoupon": "Apply coupon", "applyCoupon": "Apply coupon",
"couponPlaceholder": "Coupon code", "couponPlaceholder": "Coupon code",
@@ -377,7 +404,8 @@
"tabs": { "tabs": {
"attendance": "Attendance", "attendance": "Attendance",
"leave": "Leave", "leave": "Leave",
"payroll": "Payroll" "payroll": "Payroll",
"access": "Branch access"
}, },
"myAttendance": "My attendance", "myAttendance": "My attendance",
"clockIn": "Clock in", "clockIn": "Clock in",
@@ -1017,7 +1045,28 @@
"featureOff": "Not included — upgrade", "featureOff": "Not included — upgrade",
"featureMenu3dUpgrade": "3D menu is available on Pro and higher plans.", "featureMenu3dUpgrade": "3D menu is available on Pro and higher plans.",
"featureMenuAi3d": "AI 3D generation", "featureMenuAi3d": "AI 3D generation",
"featureMenuAi3dUpgrade": "AI 3D generation is on Business and Enterprise (100 per month)." "featureMenuAi3dUpgrade": "AI 3D generation is on Business and Enterprise (100 per month).",
"checkout": {
"title": "Invoice & Payment",
"subtitle": "Review your order and pay",
"backToPlans": "Back to plans",
"invalidPlan": "The selected plan is not available for online purchase.",
"invoiceLabel": "Proforma invoice",
"invoiceNo": "Invoice no.",
"issuedAt": "Issued",
"billingPeriod": "Billing period",
"monthsCount": "{count} mo",
"description": "Description",
"qty": "Qty",
"unitPrice": "Unit price",
"amount": "Amount",
"planLine": "{plan} plan subscription",
"subtotal": "Subtotal",
"total": "Amount due",
"secureNote": "Payment is processed through a secure bank gateway.",
"payTotal": "Pay {total}",
"redirecting": "Redirecting to gateway..."
}
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
+51 -2
View File
@@ -67,8 +67,33 @@
"delivery": "پیک", "delivery": "پیک",
"unknown": "کاربر" "unknown": "کاربر"
}, },
"branchSwitcher": {
"title": "شعبه فعال",
"allBranches": "همه شعب",
"selectBranch": "انتخاب شعبه"
},
"branchAccess": {
"title": "دسترسی شعب",
"staff": "کارکنان",
"noStaff": "کارمندی ثبت نشده است",
"selectStaff": "یک کارمند را برای مدیریت دسترسی انتخاب کنید",
"ownerNote": "مالک به همه شعب دسترسی دارد و نیازی به تعیین نقش شعبه‌ای ندارد.",
"noAssignments": "هنوز نقشی برای شعبه‌ای تعیین نشده است",
"loading": "در حال بارگذاری...",
"branch": "شعبه",
"role": "نقش",
"selectBranch": "انتخاب شعبه",
"add": "افزودن",
"remove": "حذف"
},
"access": {
"deniedTitle": "دسترسی به این صفحه ندارید",
"deniedBody": "نقش شما اجازه مشاهده این صفحه را ندارد. در صورت نیاز با مدیر یا مالک هماهنگ کنید."
},
"nav": { "nav": {
"aria": "منوی اصلی", "aria": "منوی اصلی",
"collapseSidebar": "جمع کردن نوار کناری",
"expandSidebar": "باز کردن نوار کناری",
"groups": { "groups": {
"operations": "عملیات روزانه", "operations": "عملیات روزانه",
"menuSales": "منو و فروش", "menuSales": "منو و فروش",
@@ -180,6 +205,8 @@
"cancelOrderConfirm": "مشتری بدون پرداخت رفته است؟ سفارش لغو می‌شود و میز آزاد می‌شود.", "cancelOrderConfirm": "مشتری بدون پرداخت رفته است؟ سفارش لغو می‌شود و میز آزاد می‌شود.",
"cancelOrderSuccess": "سفارش لغو شد", "cancelOrderSuccess": "سفارش لغو شد",
"cancelOrderError": "لغو سفارش ناموفق بود", "cancelOrderError": "لغو سفارش ناموفق بود",
"cancelReasonPlaceholder": "دلیل لغو (اختیاری)",
"cancelOrderHasPayments": "ابتدا پرداخت‌های ثبت‌شده را بازگردانید، سپس سفارش را لغو کنید",
"itemsCount": "قلم", "itemsCount": "قلم",
"applyCoupon": "اعمال کوپن", "applyCoupon": "اعمال کوپن",
"couponPlaceholder": "کد کوپن", "couponPlaceholder": "کد کوپن",
@@ -377,7 +404,8 @@
"tabs": { "tabs": {
"attendance": "حضور و غیاب", "attendance": "حضور و غیاب",
"leave": "مرخصی", "leave": "مرخصی",
"payroll": "حقوق" "payroll": "حقوق",
"access": "دسترسی شعب"
}, },
"myAttendance": "حضور من", "myAttendance": "حضور من",
"clockIn": "ورود", "clockIn": "ورود",
@@ -1018,7 +1046,28 @@
"featureOff": "غیرفعال — ارتقا دهید", "featureOff": "غیرفعال — ارتقا دهید",
"featureMenu3dUpgrade": "منوی ۳D در پلن حرفه‌ای و بالاتر فعال است.", "featureMenu3dUpgrade": "منوی ۳D در پلن حرفه‌ای و بالاتر فعال است.",
"featureMenuAi3d": "تولید ۳D با AI", "featureMenuAi3d": "تولید ۳D با AI",
"featureMenuAi3dUpgrade": "تولید ۳D با هوش مصنوعی در پلن کسب‌وکار و سازمانی (۱۰۰ بار در ماه) فعال است." "featureMenuAi3dUpgrade": "تولید ۳D با هوش مصنوعی در پلن کسب‌وکار و سازمانی (۱۰۰ بار در ماه) فعال است.",
"checkout": {
"title": "پیش‌فاکتور و پرداخت",
"subtitle": "جزئیات سفارش را بررسی و پرداخت کنید",
"backToPlans": "بازگشت به پلن‌ها",
"invalidPlan": "پلن انتخاب‌شده برای خرید آنلاین معتبر نیست.",
"invoiceLabel": "پیش‌فاکتور",
"invoiceNo": "شماره فاکتور",
"issuedAt": "تاریخ صدور",
"billingPeriod": "مدت اشتراک",
"monthsCount": "{count} ماه",
"description": "شرح",
"qty": "تعداد",
"unitPrice": "قیمت واحد",
"amount": "مبلغ",
"planLine": "اشتراک پلن {plan}",
"subtotal": "جمع جزء",
"total": "مبلغ قابل پرداخت",
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام می‌شود.",
"payTotal": "پرداخت {total}",
"redirecting": "در حال انتقال به درگاه..."
}
}, },
"settings": { "settings": {
"title": "تنظیمات", "title": "تنظیمات",
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
@@ -5,6 +5,7 @@ import { useLocale } from "next-intl";
import { useRouter } from "@/i18n/routing"; import { useRouter } from "@/i18n/routing";
import { Sidebar } from "@/components/layout/sidebar"; import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar"; import { Topbar } from "@/components/layout/topbar";
import { RouteGuard } from "@/components/auth/route-guard";
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider"; import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { useOfflineSync } from "@/lib/offline/use-offline-sync"; import { useOfflineSync } from "@/lib/offline/use-offline-sync";
@@ -31,7 +32,7 @@ export default function DashboardLayout({
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
<Topbar /> <Topbar />
<main className="min-h-0 flex-1 overflow-auto p-6 bg-background"> <main className="min-h-0 flex-1 overflow-auto p-6 bg-background">
{children} <RouteGuard>{children}</RouteGuard>
</main> </main>
</div> </div>
); );
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import { CheckoutScreen } from "@/components/subscription/checkout-screen";
export default function SubscriptionCheckoutPage() {
return (
<Suspense fallback={null}>
<CheckoutScreen />
</Suspense>
);
}
@@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useLocale } from "next-intl"; import { useLocale } from "next-intl";
import { useRouter } from "@/i18n/routing"; import { useRouter } from "@/i18n/routing";
import { useAuthStore } from "@/lib/stores/auth.store"; 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. */ /** Full-viewport routes (queue TV display) — auth only, no dashboard chrome. */
export default function FullscreenLayout({ children }: { children: React.ReactNode }) { export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
@@ -19,7 +20,7 @@ export default function FullscreenLayout({ children }: { children: React.ReactNo
return ( return (
<div className="min-h-svh" dir={locale === "en" ? "ltr" : "rtl"}> <div className="min-h-svh" dir={locale === "en" ? "ltr" : "rtl"}>
{children} <RouteGuard>{children}</RouteGuard>
</div> </div>
); );
} }
@@ -0,0 +1,50 @@
"use client";
import { useLocale } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
/**
* POS route layout — wraps the terminal in the standard dashboard chrome
* (collapsible sidebar + topbar) but keeps the main content area
* overflow-hidden so PosScreen can manage its own internal scrolling.
*/
export default function PosLayout({
children,
}: {
children: React.ReactNode;
}) {
const locale = useLocale();
const isRtl = locale !== "en";
const mainColumn = (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<Topbar />
<main className="min-h-0 flex-1 overflow-hidden bg-background p-3 md:p-4">
{children}
</main>
</div>
);
return (
<CafeThemeProvider>
<div
className="flex h-screen min-h-0 overflow-hidden bg-background"
dir={isRtl ? "rtl" : "ltr"}
>
{isRtl ? (
<>
<Sidebar side="right" />
{mainColumn}
</>
) : (
<>
<Sidebar side="left" />
{mainColumn}
</>
)}
</div>
</CafeThemeProvider>
);
}
@@ -1,16 +1,11 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
import { PosScreen } from "@/components/pos/pos-screen"; import { PosScreen } from "@/components/pos/pos-screen";
/** Full-viewport POS terminal — no sidebar, no topbar. */ /** POS terminal — chrome (sidebar + topbar) is provided by layout.tsx */
export default function PosPage() { export default function PosPage() {
return ( return (
<CafeThemeProvider>
<div className="flex h-screen min-h-0 flex-col overflow-hidden bg-background p-4 md:p-6">
<Suspense fallback={null}> <Suspense fallback={null}>
<PosScreen /> <PosScreen />
</Suspense> </Suspense>
</div>
</CafeThemeProvider>
); );
} }
+27
View File
@@ -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
* <Can permission="HandlePayments">
* <Button onClick={refund}>Refund</Button>
* </Can>
*/
export function Can({
permission,
children,
fallback = null,
}: {
permission: Permission;
children: ReactNode;
fallback?: ReactNode;
}) {
const allowed = useHasPermission(permission);
return <>{allowed ? children : fallback}</>;
}
@@ -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 (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-red-50 text-[#A32D2D]">
<ShieldX className="h-7 w-7" aria-hidden />
</span>
<h2 className="text-lg font-semibold text-foreground">{t("deniedTitle")}</h2>
<p className="max-w-sm text-sm text-muted-foreground">{t("deniedBody")}</p>
</div>
);
}
@@ -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<string | null>(null);
const [newBranchId, setNewBranchId] = useState("");
const [newRole, setNewRole] = useState<string>("Cashier");
const { data: employees = [] } = useQuery({
queryKey: ["employees", cafeId],
queryFn: () => apiGet<Employee[]>(`/api/cafes/${cafeId}/employees`),
enabled: !!cafeId,
});
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<Branch[]>(`/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 (
<div className="grid gap-4 lg:grid-cols-[260px_1fr]">
{/* Employee picker */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("staff")}</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
{employees.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("noStaff")}</p>
) : (
employees.map((e) => (
<button
key={e.id}
type="button"
onClick={() => setSelectedId(e.id)}
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-start text-sm transition-colors cursor-pointer ${
selectedId === e.id ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<span className="truncate">{e.name}</span>
<Badge variant="outline" className="shrink-0">
{tRoles(roleKey(e.role))}
</Badge>
</button>
))
)}
</CardContent>
</Card>
{/* Assignment editor */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!selected ? (
<p className="text-sm text-muted-foreground">{t("selectStaff")}</p>
) : isOwner ? (
<p className="text-sm text-muted-foreground">{t("ownerNote")}</p>
) : (
<>
{loadingAssignments ? (
<p className="text-sm text-muted-foreground">{t("loading")}</p>
) : assignments.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("noAssignments")}</p>
) : (
<ul className="space-y-2">
{assignments.map((a: BranchRoleAssignment) => (
<li
key={a.id}
className="flex items-center justify-between gap-2 rounded-md border border-border px-3 py-2"
>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{a.branchName}
</span>
<select
className="rounded-md border border-input bg-background px-2 py-1 text-sm"
value={a.role}
onChange={(e) => update.mutate({ assignmentId: a.id, role: e.target.value })}
disabled={update.isPending}
aria-label={t("role")}
>
{ASSIGNABLE_ROLES.map((r) => (
<option key={r} value={r}>
{tRoles(roleKey(r))}
</option>
))}
</select>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive cursor-pointer"
onClick={() => remove.mutate(a.id)}
disabled={remove.isPending}
title={t("remove")}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
<span className="sr-only">{t("remove")}</span>
</Button>
</li>
))}
</ul>
)}
{/* Add new assignment */}
<div className="flex flex-wrap items-end gap-2 border-t border-border pt-4">
<div className="flex-1 min-w-[140px]">
<label className="mb-1 block text-xs text-muted-foreground">{t("branch")}</label>
<select
className="w-full rounded-md border border-input bg-background px-2 py-2 text-sm"
value={newBranchId}
onChange={(e) => setNewBranchId(e.target.value)}
>
<option value="">{t("selectBranch")}</option>
{availableBranches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
<div className="min-w-[120px]">
<label className="mb-1 block text-xs text-muted-foreground">{t("role")}</label>
<select
className="w-full rounded-md border border-input bg-background px-2 py-2 text-sm"
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
>
{ASSIGNABLE_ROLES.map((r) => (
<option key={r} value={r}>
{tRoles(roleKey(r))}
</option>
))}
</select>
</div>
<Button
onClick={() => assign.mutate()}
disabled={!newBranchId || assign.isPending}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" aria-hidden />
{t("add")}
</Button>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
@@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
interface Employee { interface Employee {
id: string; id: string;
@@ -46,12 +47,14 @@ interface Salary {
isPaid: boolean; isPaid: boolean;
} }
type Tab = "attendance" | "leave" | "payroll"; type Tab = "attendance" | "leave" | "payroll" | "access";
export function HrScreen() { export function HrScreen() {
const t = useTranslations("hr"); const t = useTranslations("hr");
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const userId = useAuthStore((s) => s.user?.userId); const userId = useAuthStore((s) => s.user?.userId);
const role = useAuthStore((s) => s.user?.role);
const canManageAccess = role === "Owner" || role === "Manager";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>("attendance"); const [tab, setTab] = useState<Tab>("attendance");
const [monthYear, setMonthYear] = useState( const [monthYear, setMonthYear] = useState(
@@ -119,7 +122,9 @@ export function HrScreen() {
<h2 className="text-xl font-bold">{t("title")}</h2> <h2 className="text-xl font-bold">{t("title")}</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(["attendance", "leave", "payroll"] as Tab[]).map((key) => ( {((["attendance", "leave", "payroll", "access"] as Tab[]).filter(
(key) => key !== "access" || canManageAccess
)).map((key) => (
<Button <Button
key={key} key={key}
size="sm" size="sm"
@@ -223,6 +228,8 @@ export function HrScreen() {
))} ))}
</div> </div>
)} )}
{tab === "access" && canManageAccess && <BranchAccessPanel cafeId={cafeId} />}
</div> </div>
); );
} }
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 max-w-[160px] gap-1.5 px-2.5 text-xs cursor-pointer"
disabled={pending}
title={t("title")}
>
{pending ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden />
) : (
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
)}
<span className="truncate">{activeLabel}</span>
<ChevronsUpDown className="h-3 w-3 shrink-0 text-muted-foreground/60" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[200px]">
<p className="px-2 py-1.5 text-xs font-medium text-muted-foreground">{t("title")}</p>
<div className="my-1 h-px bg-border" />
{owner && (
<DropdownMenuItem
onClick={() => choose(null)}
className="cursor-pointer gap-2"
>
<Check
className={`h-3.5 w-3.5 shrink-0 ${user.isCafeWide ? "opacity-100" : "opacity-0"}`}
aria-hidden
/>
{t("allBranches")}
</DropdownMenuItem>
)}
{branches.map((b) => (
<DropdownMenuItem
key={b.branchId}
onClick={() => choose(b.branchId)}
className="cursor-pointer gap-2"
>
<Check
className={`h-3.5 w-3.5 shrink-0 ${
!user.isCafeWide && user.branchId === b.branchId ? "opacity-100" : "opacity-0"
}`}
aria-hidden
/>
<span className="truncate">{b.branchName}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+139 -18
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react"; import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/routing"; import { Link, usePathname } from "@/i18n/routing";
import { canSeeNavGroup, canSeeNavItem } from "@/lib/auth-permissions"; import { canSeeNavGroup, canSeeNavItem } from "@/lib/auth-permissions";
import { permissionsOf } from "@/lib/permissions";
import { import {
NAV_GROUPS, NAV_GROUPS,
NAV_GROUPS_STORAGE_KEY, NAV_GROUPS_STORAGE_KEY,
@@ -18,6 +19,17 @@ import { cn } from "@/lib/utils";
type OpenGroupsState = Partial<Record<NavGroupId, boolean>>; type OpenGroupsState = Partial<Record<NavGroupId, boolean>>;
const SIDEBAR_COLLAPSED_STORAGE_KEY = "meezi.sidebar.collapsed";
function readStoredCollapsed(): boolean {
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function readStoredOpenGroups(): OpenGroupsState { function readStoredOpenGroups(): OpenGroupsState {
if (typeof window === "undefined") return {}; if (typeof window === "undefined") return {};
try { try {
@@ -50,17 +62,22 @@ function NavLink({
item, item,
label, label,
active, active,
collapsed,
}: { }: {
item: NavItemDef; item: NavItemDef;
label: string; label: string;
active: boolean; active: boolean;
collapsed: boolean;
}) { }) {
const Icon = item.icon; const Icon = item.icon;
return ( return (
<Link <Link
href={item.href} href={item.href}
title={collapsed ? label : undefined}
aria-label={collapsed ? label : undefined}
className={cn( className={cn(
"group flex items-center rounded-lg px-3 py-2 text-sm transition-colors cursor-pointer", "group flex items-center rounded-lg py-2 text-sm transition-colors cursor-pointer",
collapsed ? "justify-center px-0" : "px-3",
active active
? "bg-accent text-accent-foreground font-medium" ? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
@@ -68,11 +85,12 @@ function NavLink({
> >
<Icon <Icon
className={cn( className={cn(
"h-4 w-4 shrink-0 me-2.5", "h-4 w-4 shrink-0",
collapsed ? "" : "me-2.5",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground" active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} )}
/> />
<span className="min-w-0 truncate">{label}</span> {!collapsed && <span className="min-w-0 truncate">{label}</span>}
</Link> </Link>
); );
} }
@@ -85,7 +103,9 @@ function NavGroupSection({
pathname, pathname,
role, role,
branchId, branchId,
permissions,
tItem, tItem,
collapsed,
}: { }: {
group: NavGroupDef; group: NavGroupDef;
title: string; title: string;
@@ -94,13 +114,37 @@ function NavGroupSection({
pathname: string; pathname: string;
role: string | undefined; role: string | undefined;
branchId: string | null | undefined; branchId: string | null | undefined;
permissions: Set<string> | null;
tItem: (key: string) => string; tItem: (key: string) => string;
collapsed: boolean;
}) { }) {
const visibleItems = group.items.filter((item) => const visibleItems = group.items.filter((item) =>
canSeeNavItem(item.key, role, branchId) canSeeNavItem(item.key, role, branchId, permissions)
); );
if (visibleItems.length === 0) return null; if (visibleItems.length === 0) return null;
// Collapsed: drop the group header, show items as a flat icon-only list
// with a subtle divider between groups.
if (collapsed) {
return (
<div className="mb-1 space-y-0.5 border-t border-border/60 pt-1 first:border-t-0 first:pt-0">
{visibleItems.map((item) => {
const active =
pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<NavLink
key={item.key}
item={item}
label={tItem(item.key)}
active={active}
collapsed
/>
);
})}
</div>
);
}
return ( return (
<div className="mb-1"> <div className="mb-1">
<button <button
@@ -137,6 +181,7 @@ function NavGroupSection({
item={item} item={item}
label={tItem(item.key)} label={tItem(item.key)}
active={active} active={active}
collapsed={false}
/> />
); );
})} })}
@@ -156,16 +201,41 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
const hasHydrated = useAuthStore((s) => s._hasHydrated); const hasHydrated = useAuthStore((s) => s._hasHydrated);
const role = user?.role; const role = user?.role;
const branchId = user?.branchId ?? null; const branchId = user?.branchId ?? null;
const permissions = useMemo(() => permissionsOf(user), [user]);
const [openGroups, setOpenGroups] = useState<OpenGroupsState>(buildDefaultOpenGroups); const [openGroups, setOpenGroups] = useState<OpenGroupsState>(buildDefaultOpenGroups);
const [collapsed, setCollapsed] = useState<boolean>(readStoredCollapsed);
const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, next ? "1" : "0");
} catch {
/* ignore quota */
}
return next;
});
}, []);
// Chevron points "inward" (toward the panel) to collapse, "outward" to expand.
// For a left-docked sidebar inward = left; for a right-docked one inward = right.
const CollapseIcon =
side === "left"
? collapsed
? ChevronRight
: ChevronLeft
: collapsed
? ChevronLeft
: ChevronRight;
const visibleGroups = useMemo( const visibleGroups = useMemo(
() => () =>
NAV_GROUPS.filter((g) => { NAV_GROUPS.filter((g) => {
if (!canSeeNavGroup(g.id, role, branchId)) return false; 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) => { const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => {
@@ -190,21 +260,30 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
return ( return (
<aside <aside
className={cn( className={cn(
"flex w-56 shrink-0 flex-col bg-background", "flex shrink-0 flex-col bg-background overflow-hidden",
"transition-[width] duration-200 ease-in-out",
collapsed ? "w-14" : "w-56",
"border-border", "border-border",
side === "right" ? "border-s" : "border-e" side === "right" ? "border-s" : "border-e"
)} )}
> >
{/* Logo */} {/* Logo */}
<div className="flex h-14 items-center gap-2.5 px-4 border-b border-border"> <div
className={cn(
"flex h-14 shrink-0 items-center border-b border-border",
collapsed ? "justify-center px-0" : "gap-2.5 px-4"
)}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10"> <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-primary" aria-hidden> <svg viewBox="0 0 24 24" className="h-4 w-4 fill-primary" aria-hidden>
<path d="M3 6h18v2H3V6zm2 4h14v2H5v-2zm-2 4h18v2H3v-2zm4 4h10v2H7v-2z" /> <path d="M3 6h18v2H3V6zm2 4h14v2H5v-2zm-2 4h18v2H3v-2zm4 4h10v2H7v-2z" />
</svg> </svg>
</div> </div>
<span className="text-sm font-bold tracking-tight text-foreground"> {!collapsed && (
<span className="min-w-0 truncate text-sm font-bold tracking-tight text-foreground">
{tBrand("name")} {tBrand("name")}
</span> </span>
)}
</div> </div>
{/* Nav */} {/* Nav */}
@@ -220,6 +299,15 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
/* Skeleton shown for ~50ms until Zustand rehydrates from localStorage. /* Skeleton shown for ~50ms until Zustand rehydrates from localStorage.
Prevents the flash where all groups are briefly visible before Prevents the flash where all groups are briefly visible before
permission-based filtering kicks in for branch-scoped accounts. */ permission-based filtering kicks in for branch-scoped accounts. */
collapsed ? (
<div className="space-y-1.5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex justify-center">
<div className="h-8 w-8 animate-pulse rounded-lg bg-muted" />
</div>
))}
</div>
) : (
<div className="space-y-3 px-1 pt-1"> <div className="space-y-3 px-1 pt-1">
{[40, 32, 40, 32, 40].map((w, i) => ( {[40, 32, 40, 32, 40].map((w, i) => (
<div key={i} className="space-y-1.5"> <div key={i} className="space-y-1.5">
@@ -227,13 +315,14 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
{Array.from({ length: i % 2 === 0 ? 3 : 2 }).map((_, j) => ( {Array.from({ length: i % 2 === 0 ? 3 : 2 }).map((_, j) => (
<div <div
key={j} key={j}
className={`h-8 animate-pulse rounded-lg bg-muted`} className="h-8 animate-pulse rounded-lg bg-muted"
style={{ width: `${w + j * 4}%` }} style={{ width: `${w + j * 4}%` }}
/> />
))} ))}
</div> </div>
))} ))}
</div> </div>
)
) : ( ) : (
visibleGroups.map((group) => { visibleGroups.map((group) => {
const isOpen = openGroups[group.id] ?? group.defaultOpen; const isOpen = openGroups[group.id] ?? group.defaultOpen;
@@ -247,31 +336,63 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
pathname={pathname} pathname={pathname}
role={role} role={role}
branchId={branchId} branchId={branchId}
permissions={permissions}
tItem={(key) => t(key)} tItem={(key) => t(key)}
collapsed={collapsed}
/> />
); );
}) })
)} )}
</nav> </nav>
{/* Footer — user role badge */} {/* Footer — user info + collapse toggle */}
<div className="shrink-0 border-t border-border">
{/* User badge */}
{user && ( {user && (
<div className="border-t border-border px-4 py-3"> <div
<div className="flex items-center gap-2"> className={cn(
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10"> "flex items-center py-3",
collapsed ? "justify-center px-0" : "gap-2 px-4"
)}
>
<div
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10"
title={collapsed ? (user.actor ?? user.userId) : undefined}
>
<span className="text-[11px] font-semibold text-primary"> <span className="text-[11px] font-semibold text-primary">
{(user.actor ?? user.role).charAt(0).toUpperCase()} {(user.actor ?? user.role).charAt(0).toUpperCase()}
</span> </span>
</div> </div>
{!collapsed && (
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground"> <p className="truncate text-xs font-medium text-foreground">
{user.actor ?? user.userId} {user.actor ?? user.userId}
</p> </p>
<p className="truncate text-[10px] text-muted-foreground">{user.role}</p> <p className="truncate text-[10px] text-muted-foreground">
</div> {user.role}
</div> </p>
</div> </div>
)} )}
</div>
)}
{/* Collapse toggle */}
<button
type="button"
onClick={toggleCollapsed}
title={collapsed ? t("expandSidebar") : t("collapseSidebar")}
aria-label={collapsed ? t("expandSidebar") : t("collapseSidebar")}
className={cn(
"flex w-full items-center gap-2 border-t border-border/50 py-2.5 text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground cursor-pointer",
collapsed ? "justify-center px-0" : "px-4"
)}
>
<CollapseIcon className="h-4 w-4 shrink-0" aria-hidden />
{!collapsed && (
<span className="text-xs">{t("collapseSidebar")}</span>
)}
</button>
</div>
</aside> </aside>
); );
} }
@@ -14,6 +14,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster"; import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
import { BranchSwitcher } from "@/components/layout/branch-switcher";
import { NotificationCenter } from "@/components/notifications/notification-center"; import { NotificationCenter } from "@/components/notifications/notification-center";
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator"; import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
@@ -62,6 +63,7 @@ export function Topbar() {
{/* Actions */} {/* Actions */}
<div className="flex flex-1 items-center justify-end gap-1.5"> <div className="flex flex-1 items-center justify-end gap-1.5">
<BranchSwitcher />
<SyncStatusIndicator /> <SyncStatusIndicator />
<NotificationCenter /> <NotificationCenter />
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import * as signalR from "@microsoft/signalr"; 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 { printErrorMessage, printReceipt } from "@/lib/api/print";
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device"; import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { PosSlipModal } from "@/components/pos/pos-slip-modal"; import { PosSlipModal } from "@/components/pos/pos-slip-modal";
@@ -27,6 +27,13 @@ type PaymentRow = {
amount: string; amount: string;
}; };
type BranchPrintSettings = {
receiptHeader?: string | null;
receiptFooter?: string | null;
wifiPassword?: string | null;
paperWidthMm?: number;
};
type PosPayPanelProps = { type PosPayPanelProps = {
cafeId: string; cafeId: string;
numberLocale: string; numberLocale: string;
@@ -47,7 +54,9 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
const [payMessage, setPayMessage] = useState<string | null>(null); const [payMessage, setPayMessage] = useState<string | null>(null);
const [cancelReason, setCancelReason] = useState("");
const [receiptOrder, setReceiptOrder] = useState<Order | null>(null); const [receiptOrder, setReceiptOrder] = useState<Order | null>(null);
const printSettingsBranchId = receiptOrder?.branchId ?? branchId ?? null;
const [lastPaidOrderId, setLastPaidOrderId] = useState<string | null>(null); const [lastPaidOrderId, setLastPaidOrderId] = useState<string | null>(null);
const [paymentRows, setPaymentRows] = useState<PaymentRow[]>([ const [paymentRows, setPaymentRows] = useState<PaymentRow[]>([
{ method: "Cash", amount: "" }, { method: "Cash", amount: "" },
@@ -79,6 +88,16 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
enabled: !!cafeId, enabled: !!cafeId,
}); });
const { data: printSettings } = useQuery({
queryKey: ["branch-print-settings", cafeId, printSettingsBranchId],
queryFn: () =>
apiGet<BranchPrintSettings>(
`/api/cafes/${cafeId}/branches/${printSettingsBranchId}/print-settings`
),
enabled: !!cafeId && !!printSettingsBranchId,
staleTime: 5 * 60 * 1000,
});
const displayedOrders = useMemo(() => { const displayedOrders = useMemo(() => {
if (!filterTableId) return openOrders; if (!filterTableId) return openOrders;
return openOrders.filter((o) => o.tableId === filterTableId); return openOrders.filter((o) => o.tableId === filterTableId);
@@ -149,6 +168,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
useEffect(() => { useEffect(() => {
setLoyaltyRedeem(0); setLoyaltyRedeem(0);
setCancelReason("");
}, [selected?.id]); }, [selected?.id]);
useEffect(() => { useEffect(() => {
@@ -228,23 +248,31 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
}); });
const cancelOrder = useMutation({ const cancelOrder = useMutation({
mutationFn: (orderId: string) => mutationFn: ({ orderId, reason }: { orderId: string; reason: string }) =>
apiPatch(`/api/cafes/${cafeId}/orders/${orderId}/status`, { apiPost(`/api/cafes/${cafeId}/orders/${orderId}/cancel`, {
status: "Cancelled", reason: reason.trim() || undefined,
}), }),
onSuccess: () => { onSuccess: () => {
setPayMessage(t("cancelOrderSuccess")); setPayMessage(t("cancelOrderSuccess"));
setCancelReason("");
setSelectedId(null); setSelectedId(null);
setSelectedTableId(null); setSelectedTableId(null);
setFilterTableId(null); setFilterTableId(null);
setPaymentRows([{ method: "Cash", amount: "" }]); setPaymentRows([{ method: "Cash", amount: "" }]);
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] }); queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] }); queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
}, },
onError: (err) => { onError: (err) => {
setPayMessage( if (err instanceof ApiClientError) {
err instanceof ApiClientError ? err.message : t("cancelOrderError") if (err.code === "ORDER_HAS_PAYMENTS") {
); setPayMessage(t("cancelOrderHasPayments"));
return;
}
setPayMessage(err.message || t("cancelOrderError"));
return;
}
setPayMessage(t("cancelOrderError"));
}, },
}); });
@@ -578,6 +606,13 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
> >
{t("previewBill")} {t("previewBill")}
</Button> </Button>
<Input
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder={t("cancelReasonPlaceholder")}
className="h-9"
maxLength={500}
/>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -591,7 +626,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
confirmLabel: t("cancelOrder"), confirmLabel: t("cancelOrder"),
}); });
if (!ok) return; if (!ok) return;
cancelOrder.mutate(selected.id); cancelOrder.mutate({ orderId: selected.id, reason: cancelReason });
}} }}
> >
{cancelOrder.isPending ? "..." : t("cancelOrder")} {cancelOrder.isPending ? "..." : t("cancelOrder")}
@@ -630,6 +665,10 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
.filter(Boolean) .filter(Boolean)
.join(" • ") || undefined .join(" • ") || undefined
} }
receiptHeader={printSettings?.receiptHeader}
receiptFooter={printSettings?.receiptFooter}
wifiPassword={printSettings?.wifiPassword}
paperWidthMm={printSettings?.paperWidthMm}
onClose={() => setReceiptOrder(null)} onClose={() => setReceiptOrder(null)}
/> />
) : null} ) : null}
@@ -7,7 +7,6 @@ import { useTranslations, useLocale } from "next-intl";
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
LayoutDashboard,
Minus, Minus,
Package, Package,
Plus, Plus,
@@ -902,17 +901,6 @@ export function PosScreen() {
</Button> </Button>
<div className="flex-1" /> <div className="flex-1" />
{/* Dashboard shortcut — only visible to Owner / Manager */}
{isManager && (
<a
href="/"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<LayoutDashboard className="size-4" />
<span className="hidden sm:inline">{cafeName}</span>
</a>
)}
</div> </div>
{/* ── Pay mode ──────────────────────────────────────────────────────── */} {/* ── Pay mode ──────────────────────────────────────────────────────── */}
@@ -23,6 +23,14 @@ type PosSlipModalProps = {
logoUrl?: string; logoUrl?: string;
/** Address / phone line shown under the café name on the bill. */ /** Address / phone line shown under the café name on the bill. */
tagline?: string; tagline?: string;
/** Custom header note from branch print settings (bill only). */
receiptHeader?: string | null;
/** Custom footer note from branch print settings (bill only). */
receiptFooter?: string | null;
/** WiFi password printed near the bill footer. */
wifiPassword?: string | null;
/** Paper width in mm — 58 or 80 (default 80). */
paperWidthMm?: number;
onClose: () => void; onClose: () => void;
/** Full order for customer bill */ /** Full order for customer bill */
order?: Order; order?: Order;
@@ -39,6 +47,10 @@ export function PosSlipModal({
cafeName, cafeName,
logoUrl, logoUrl,
tagline, tagline,
receiptHeader,
receiptFooter,
wifiPassword,
paperWidthMm,
onClose, onClose,
order, order,
kitchenLines = [], kitchenLines = [],
@@ -103,6 +115,9 @@ export function PosSlipModal({
cafeName, cafeName,
logoUrl: resolveMediaUrl(logoUrl), logoUrl: resolveMediaUrl(logoUrl),
tagline, tagline,
header: receiptHeader?.trim() || undefined,
wifi: wifiPassword?.trim() || undefined,
paperWidthMm,
title: t("billTitle"), title: t("billTitle"),
date: formattedDate, date: formattedDate,
metaRow, metaRow,
@@ -118,7 +133,7 @@ export function PosSlipModal({
amount: formatCurrency(p.amount, numberLocale), amount: formatCurrency(p.amount, numberLocale),
})), })),
}, },
footer: t("thankYou"), footer: receiptFooter?.trim() || t("thankYou"),
locale, locale,
}; };
@@ -147,6 +162,11 @@ export function PosSlipModal({
{variant === "bill" && tagline && ( {variant === "bill" && tagline && (
<div className="text-center text-[10px] text-muted-foreground">{tagline}</div> <div className="text-center text-[10px] text-muted-foreground">{tagline}</div>
)} )}
{variant === "bill" && receiptHeader?.trim() && (
<div className="whitespace-pre-line text-center text-[11px] font-medium text-foreground/80">
{receiptHeader.trim()}
</div>
)}
<div className="mb-1 mt-1.5 border-y border-foreground/60 py-0.5 text-center text-xs font-bold"> <div className="mb-1 mt-1.5 border-y border-foreground/60 py-0.5 text-center text-xs font-bold">
{variant === "kitchen" ? t("kitchenTitle") : t("billTitle")} {variant === "kitchen" ? t("kitchenTitle") : t("billTitle")}
</div> </div>
@@ -191,7 +211,14 @@ export function PosSlipModal({
</div> </div>
))} ))}
<div className="receipt-divider" /> <div className="receipt-divider" />
<div className="mt-2 text-center text-xs">{t("thankYou")}</div> {wifiPassword?.trim() && (
<div className="text-center text-[11px]" dir="ltr">
WiFi: {wifiPassword.trim()}
</div>
)}
<div className="mt-2 text-center text-xs">
{receiptFooter?.trim() || t("thankYou")}
</div>
</> </>
)} )}
@@ -9,9 +9,9 @@ import { Badge } from "@/components/ui/badge";
export type PlanId = "Free" | "Pro" | "Business" | "Enterprise"; export type PlanId = "Free" | "Pro" | "Business" | "Enterprise";
const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"]; export const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"];
const PRICES: Record<PlanId, number | null> = { export const PRICES: Record<PlanId, number | null> = {
Free: 0, Free: 0,
Pro: 1_490_000, Pro: 1_490_000,
Business: 3_490_000, Business: 3_490_000,
@@ -69,8 +69,8 @@ const FEATURE_MATRIX: FeatureRow[] = [
key: "branches", key: "branches",
cells: { cells: {
Free: { kind: "limit", value: 1 }, Free: { kind: "limit", value: 1 },
Pro: { kind: "limit", value: 1 }, Pro: { kind: "limit", value: 3 },
Business: { kind: "limit", value: 5 }, Business: { kind: "limit", value: null },
Enterprise: { kind: "limit", value: null }, Enterprise: { kind: "limit", value: null },
}, },
}, },
@@ -0,0 +1,281 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/routing";
import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client";
import { isCafeOwner } from "@/lib/auth-permissions";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatCurrency, formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/layout/page-header";
import { PRICES, type PlanId } from "@/components/settings/plan-comparison";
type SubscribeResponse = {
paymentId: string;
paymentUrl: string;
};
type PaymentMethod = {
id: string;
displayNameFa: string;
isDefault: boolean;
};
const BILLABLE_PLANS: PlanId[] = ["Pro", "Business"];
const MONTH_OPTIONS = [1, 3, 6, 12];
export function CheckoutScreen() {
const t = useTranslations("subscription");
const tc = useTranslations("subscription.checkout");
const tPlans = useTranslations("settings.plans");
const searchParams = useSearchParams();
const router = useRouter();
const user = useAuthStore((s) => s.user);
const cafeId = user?.cafeId;
const role = user?.role;
const planParam = searchParams.get("plan") as PlanId | null;
const isBillable = !!planParam && BILLABLE_PLANS.includes(planParam);
const plan = (isBillable ? planParam : "Pro") as PlanId;
const [months, setMonths] = useState(1);
const [paymentMethod, setPaymentMethod] = useState("");
const numberLocale =
typeof document !== "undefined" && document.documentElement.lang === "en"
? "en-US"
: "fa-IR";
const isRtl = numberLocale !== "en-US";
const cafeName = useMemo(() => {
if (!user) return "";
const membership = user.memberships?.find((m) => m.cafeId === user.cafeId);
return membership?.cafeName ?? "";
}, [user]);
const { data: paymentMethods = [] } = useQuery({
queryKey: ["billing-payment-methods", cafeId],
queryFn: () => apiGet<PaymentMethod[]>("/api/billing/payment-methods"),
enabled: !!cafeId && isCafeOwner(role),
});
useEffect(() => {
if (!paymentMethod && paymentMethods.length > 0) {
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
setPaymentMethod(def.id);
}
}, [paymentMethods, paymentMethod]);
const subscribe = useMutation({
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
onSuccess: (data) => {
window.location.href = data.paymentUrl;
},
});
if (!cafeId) return null;
if (!isCafeOwner(role)) {
return (
<div className="space-y-6">
<PageHeader title={tc("title")} subtitle={tc("subtitle")} />
<p className="text-sm text-muted-foreground">{t("ownerOnly")}</p>
</div>
);
}
if (!isBillable) {
return (
<div className="space-y-6">
<PageHeader title={tc("title")} subtitle={tc("subtitle")} />
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardContent className="space-y-4 py-8 text-center">
<p className="text-sm text-muted-foreground">{tc("invalidPlan")}</p>
<Button variant="outline" onClick={() => router.push("/subscription")}>
{tc("backToPlans")}
</Button>
</CardContent>
</Card>
</div>
);
}
const unitPrice = PRICES[plan] ?? 0;
const subtotal = unitPrice * months;
const total = subtotal;
const planName = tPlans(`names.${plan}`);
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
const issuedAt = new Date().toLocaleDateString(numberLocale);
const invoiceNo = `MZ-${Date.now().toString().slice(-8)}`;
return (
<div className="mx-auto max-w-3xl space-y-6">
<PageHeader
title={tc("title")}
subtitle={tc("subtitle")}
action={
<Button
variant="ghost"
size="sm"
className="gap-1.5"
onClick={() => router.push("/subscription")}
>
<BackIcon className="h-4 w-4" aria-hidden />
{tc("backToPlans")}
</Button>
}
/>
{/* Factor / invoice */}
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
{/* Invoice header */}
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/80 bg-muted/30 px-5 py-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{tc("invoiceLabel")}
</p>
<p className="mt-0.5 text-base font-semibold text-foreground">
{cafeName || tc("invoiceLabel")}
</p>
</div>
<dl className="text-end text-xs text-muted-foreground">
<div className="flex items-center justify-end gap-1.5">
<dt>{tc("invoiceNo")}:</dt>
<dd className="font-medium text-foreground" dir="ltr">
{invoiceNo}
</dd>
</div>
<div className="mt-0.5 flex items-center justify-end gap-1.5">
<dt>{tc("issuedAt")}:</dt>
<dd className="font-medium text-foreground">{issuedAt}</dd>
</div>
</dl>
</div>
<CardContent className="space-y-6 px-5 py-5">
{/* Billing period selector */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">{tc("billingPeriod")}</p>
<div className="flex flex-wrap gap-2">
{MONTH_OPTIONS.map((m) => (
<button
key={m}
type="button"
onClick={() => setMonths(m)}
className={cn(
"rounded-lg border px-3.5 py-2 text-sm transition active:scale-[0.98]",
months === m
? "border-[#0F6E56] bg-[#E1F5EE] font-medium text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{tc("monthsCount", { count: formatNumber(m, numberLocale) })}
</button>
))}
</div>
</div>
{/* Line items */}
<div className="overflow-hidden rounded-lg border border-border/60">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/60 bg-muted/20 text-[11px] uppercase tracking-[0.06em] text-muted-foreground">
<th className="px-4 py-2.5 text-start font-medium">{tc("description")}</th>
<th className="px-4 py-2.5 text-center font-medium">{tc("qty")}</th>
<th className="px-4 py-2.5 text-end font-medium">{tc("unitPrice")}</th>
<th className="px-4 py-2.5 text-end font-medium">{tc("amount")}</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border/40">
<td className="px-4 py-3 text-start">
<span className="font-medium text-foreground">
{tc("planLine", { plan: planName })}
</span>
</td>
<td className="px-4 py-3 text-center text-muted-foreground">
{tc("monthsCount", { count: formatNumber(months, numberLocale) })}
</td>
<td className="px-4 py-3 text-end text-muted-foreground" dir="ltr">
{formatCurrency(unitPrice, numberLocale)}
</td>
<td className="px-4 py-3 text-end font-medium text-foreground" dir="ltr">
{formatCurrency(subtotal, numberLocale)}
</td>
</tr>
</tbody>
</table>
</div>
{/* Totals */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{tc("subtotal")}</span>
<span dir="ltr">{formatCurrency(subtotal, numberLocale)}</span>
</div>
<div className="flex items-center justify-between border-t border-border/60 pt-2.5 text-base font-semibold text-foreground">
<span>{tc("total")}</span>
<span className="text-[#0F6E56]" dir="ltr">
{formatCurrency(total, numberLocale)}
</span>
</div>
</div>
{/* Payment method */}
{paymentMethods.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">{t("paymentMethod")}</p>
<div className="flex flex-wrap gap-2">
{paymentMethods.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setPaymentMethod(m.id)}
className={cn(
"rounded-lg border px-3 py-2 text-sm transition active:scale-[0.98]",
paymentMethod === m.id
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{m.displayNameFa}
</button>
))}
</div>
</div>
) : null}
</CardContent>
{/* Pay action */}
<div className="flex flex-col gap-3 border-t border-border/80 bg-muted/20 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="h-4 w-4 text-[#0F6E56]" aria-hidden />
{tc("secureNote")}
</p>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={subscribe.isPending}
onClick={() =>
subscribe.mutate({
planTier: plan,
months,
paymentMethod: paymentMethod || paymentMethods[0]?.id || "zarinpal",
})
}
>
{subscribe.isPending
? tc("redirecting")
: tc("payTotal", { total: formatCurrency(total, numberLocale) })}
</Button>
</div>
</Card>
</div>
);
}
@@ -2,13 +2,13 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/routing";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiGet, apiPost } from "@/lib/api/client";
import { isCafeOwner } from "@/lib/auth-permissions"; import { isCafeOwner } from "@/lib/auth-permissions";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -32,27 +32,16 @@ type BillingStatus = {
isPlanExpired: boolean; isPlanExpired: boolean;
}; };
type SubscribeResponse = {
paymentId: string;
paymentUrl: string;
};
type PaymentMethod = {
id: string;
displayNameFa: string;
isDefault: boolean;
};
export function SubscriptionScreen() { export function SubscriptionScreen() {
const t = useTranslations("subscription"); const t = useTranslations("subscription");
const tSettings = useTranslations("settings"); const tSettings = useTranslations("settings");
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role); const role = useAuthStore((s) => s.user?.role);
const setAuth = useAuthStore((s) => s.setAuth); const setAuth = useAuthStore((s) => s.setAuth);
const billingRefreshed = useRef(false); const billingRefreshed = useRef(false);
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null); const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
const [paymentMethod, setPaymentMethod] = useState("");
useEffect(() => { useEffect(() => {
const billing = searchParams.get("billing"); const billing = searchParams.get("billing");
@@ -72,19 +61,6 @@ export function SubscriptionScreen() {
enabled: !!cafeId, enabled: !!cafeId,
}); });
const { data: paymentMethods = [] } = useQuery({
queryKey: ["billing-payment-methods", cafeId],
queryFn: () => apiGet<PaymentMethod[]>("/api/billing/payment-methods"),
enabled: !!cafeId && isCafeOwner(role),
});
useEffect(() => {
if (!paymentMethod && paymentMethods.length > 0) {
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
setPaymentMethod(def.id);
}
}, [paymentMethods, paymentMethod]);
useEffect(() => { useEffect(() => {
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return; if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
const refresh = localStorage.getItem("meezi_refresh_token"); const refresh = localStorage.getItem("meezi_refresh_token");
@@ -98,14 +74,6 @@ export function SubscriptionScreen() {
.catch(() => notify.warning(tSettings("profile.reloginHint"))); .catch(() => notify.warning(tSettings("profile.reloginHint")));
}, [searchParams, setAuth, refetch, tSettings]); }, [searchParams, setAuth, refetch, tSettings]);
const subscribe = useMutation({
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
onSuccess: (data) => {
window.location.href = data.paymentUrl;
},
});
if (!cafeId) return null; if (!cafeId) return null;
if (!isCafeOwner(role)) { if (!isCafeOwner(role)) {
@@ -187,41 +155,11 @@ export function SubscriptionScreen() {
</CardContent> </CardContent>
</Card> </Card>
{paymentMethods.length > 0 ? (
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("paymentMethod")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{paymentMethods.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setPaymentMethod(m.id)}
className={cn(
"rounded-lg border px-3 py-2 text-sm transition active:scale-[0.98]",
paymentMethod === m.id
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{m.displayNameFa}
</button>
))}
</CardContent>
</Card>
) : null}
<PlanComparison <PlanComparison
currentPlan={status?.planTier ?? "Free"} currentPlan={status?.planTier ?? "Free"}
onSubscribe={(planTier) => onSubscribe={(planTier) =>
subscribe.mutate({ router.push(`/subscription/checkout?plan=${planTier}`)
planTier,
months: 1,
paymentMethod: paymentMethod || paymentMethods[0]?.id || "zarinpal",
})
} }
isSubscribing={subscribe.isPending}
/> />
</div> </div>
); );
+56
View File
@@ -0,0 +1,56 @@
import { apiGet, apiPost, apiPatch, apiDelete } from "@/lib/api/client";
import type { AuthTokenResponse } from "@/lib/api/types";
export interface BranchRoleAssignment {
id: string;
branchId: string;
branchName: string;
role: string;
}
export function listBranchRoles(cafeId: string, employeeId: string) {
return apiGet<BranchRoleAssignment[]>(
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles`
);
}
export function assignBranchRole(
cafeId: string,
employeeId: string,
body: { branchId: string; role: string }
) {
return apiPost<BranchRoleAssignment, typeof body>(
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles`,
body
);
}
export function updateBranchRole(
cafeId: string,
employeeId: string,
assignmentId: string,
role: string
) {
return apiPatch<BranchRoleAssignment, { role: string }>(
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles/${assignmentId}`,
{ role }
);
}
export function removeBranchRole(
cafeId: string,
employeeId: string,
assignmentId: string
) {
return apiDelete(
`/api/cafes/${cafeId}/employees/${employeeId}/branch-roles/${assignmentId}`
);
}
/** Re-issue the session token scoped to a branch (null = café-wide, Owner only). */
export function switchBranch(branchId: string | null) {
return apiPost<AuthTokenResponse, { branchId: string | null }>(
`/api/auth/switch-branch`,
{ branchId }
);
}
+14
View File
@@ -11,6 +11,12 @@ export interface CafeMembership {
planTier: string; planTier: string;
} }
export interface BranchMembership {
branchId: string;
branchName: string;
role: string;
}
export interface AuthTokenResponse { export interface AuthTokenResponse {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
@@ -23,6 +29,14 @@ export interface AuthTokenResponse {
actor?: string; actor?: string;
branchId?: string | null; branchId?: string | null;
memberships?: CafeMembership[] | null; memberships?: CafeMembership[] | null;
/** Display name of the currently active branch (null when café-wide). */
branchName?: string | null;
/** True when the session spans the whole café (Owner, no branch scope). */
isCafeWide?: boolean;
/** Branches this employee may operate as, with their role in each. */
branches?: BranchMembership[] | null;
/** Effective capabilities for the active role — drives page/action gating. */
permissions?: string[] | null;
} }
/** Returned (in the data field) when a phone belongs to multiple cafés. */ /** Returned (in the data field) when a phone belongs to multiple cafés. */
+13 -2
View File
@@ -1,4 +1,5 @@
import { BRANCH_ONLY_NAV_GROUP, type NavGroupId } from "@/lib/sidebar-nav"; import { BRANCH_ONLY_NAV_GROUP, type NavGroupId, type NavItemKey } from "@/lib/sidebar-nav";
import { NAV_REQUIRED_PERMISSION } from "@/lib/permissions";
/** Cafe owner (HQ) — billing, taxes, branches. */ /** Cafe owner (HQ) — billing, taxes, branches. */
export function isCafeOwner(role: string | undefined): boolean { export function isCafeOwner(role: string | undefined): boolean {
@@ -26,7 +27,8 @@ export function canSeeNavGroup(
export function canSeeNavItem( export function canSeeNavItem(
key: string, key: string,
role: string | undefined, role: string | undefined,
branchId: string | null | undefined branchId: string | null | undefined,
permissions?: Set<string> | null
): boolean { ): boolean {
if ((OWNER_ONLY_NAV_KEYS as readonly string[]).includes(key) && !isCafeOwner(role)) { if ((OWNER_ONLY_NAV_KEYS as readonly string[]).includes(key) && !isCafeOwner(role)) {
return false; return false;
@@ -34,5 +36,14 @@ export function canSeeNavItem(
if (key === "branches" && isBranchAccount(branchId)) { if (key === "branches" && isBranchAccount(branchId)) {
return false; return false;
} }
// Permission-based page visibility. `permissions === null` means a legacy
// session with no permission list — fall back to the role/branch rules above
// so those users keep their current access until the next token refresh.
if (permissions) {
const required = NAV_REQUIRED_PERMISSION[key as NavItemKey];
if (required && !permissions.has(required)) {
return false;
}
}
return true; return true;
} }
+80
View File
@@ -0,0 +1,80 @@
import { useAuthStore } from "@/lib/stores/auth.store";
import type { NavItemKey } from "@/lib/sidebar-nav";
/**
* Client mirror of the backend `Meezi.Core.Authorization.Permission` enum. The
* server (EnsurePermission) remains the single source of truth these values
* only drive what the UI *shows* (pages, action buttons). Never rely on them
* for actual security.
*/
export type Permission =
| "ManageCafeSettings"
| "ManageBilling"
| "ManageBranches"
| "ManageStaff"
| "ManageMenu"
| "ManageInventory"
| "ManageExpenses"
| "ManageTaxes"
| "ManageCoupons"
| "ManageReservations"
| "ManageTables"
| "ViewReports"
| "ReviewLeave"
| "ManageSalaries"
| "ManagePrintSettings"
| "ProcessOrders"
| "HandlePayments"
| "OperateRegister"
| "ManageQueue"
| "ViewKitchen"
| "HandleDelivery";
/**
* Permission a nav page requires to be visible. Pages not listed here fall back
* to the existing owner-only / branch-account visibility logic in
* {@link file://./auth-permissions.ts}.
*/
export const NAV_REQUIRED_PERMISSION: Partial<Record<NavItemKey, Permission>> = {
pos: "ProcessOrders",
tables: "ManageTables",
queue: "ManageQueue",
kds: "ViewKitchen",
reservations: "ManageReservations",
menu: "ManageMenu",
inventory: "ManageInventory",
coupons: "ManageCoupons",
reports: "ViewReports",
expenses: "ManageExpenses",
shifts: "OperateRegister",
taxes: "ManageTaxes",
hr: "ManageStaff",
};
/** Read the effective permission set off an auth response (null = legacy session). */
export function permissionsOf(
user: { permissions?: string[] | null } | null | undefined
): Set<string> | null {
if (!user?.permissions) return null;
return new Set(user.permissions);
}
/**
* Whether the user holds a capability. Legacy sessions (no permissions array, e.g.
* issued before this feature shipped) return `true` so the UI degrades gracefully
* until the next token refresh the server still enforces real access.
*/
export function hasPermission(
user: { permissions?: string[] | null } | null | undefined,
permission: Permission
): boolean {
const set = permissionsOf(user);
if (set === null) return true;
return set.has(permission);
}
/** React hook: does the current user hold the given permission? */
export function useHasPermission(permission: Permission): boolean {
const user = useAuthStore((s) => s.user);
return hasPermission(user, permission);
}
+36 -8
View File
@@ -44,6 +44,12 @@ export type ThermalSlipData = {
logoUrl?: string; logoUrl?: string;
/** Optional tagline / address line under the café name. */ /** Optional tagline / address line under the café name. */
tagline?: string; tagline?: string;
/** Optional custom header note (from branch print settings) shown under the name. */
header?: string;
/** Optional WiFi password line printed near the footer. */
wifi?: string;
/** Paper width in mm — 58 or 80 (default 80). Controls page width + scale. */
paperWidthMm?: number;
}; };
/** Absolute URL to the bundled Vazirmatn web-font (same origin). */ /** Absolute URL to the bundled Vazirmatn web-font (same origin). */
@@ -104,9 +110,14 @@ export function buildThermalDocument(data: ThermalSlipData): string {
`; `;
} }
const footerHtml = data.footer const wifiLabel = isRtl ? "وای‌فای" : "WiFi";
? `<hr class="dashed"><div class="center sm muted">${esc(data.footer)}</div>` const footerInner = [
: ""; data.wifi ? `<div class="center sm">${wifiLabel}: ${esc(data.wifi)}</div>` : "",
data.footer ? `<div class="center sm muted">${esc(data.footer)}</div>` : "",
]
.filter(Boolean)
.join("");
const footerHtml = footerInner ? `<hr class="dashed">${footerInner}` : "";
const logoHtml = data.logoUrl const logoHtml = data.logoUrl
? `<img class="logo" src="${esc(data.logoUrl)}" alt="">` ? `<img class="logo" src="${esc(data.logoUrl)}" alt="">`
@@ -114,6 +125,15 @@ export function buildThermalDocument(data: ThermalSlipData): string {
const taglineHtml = data.tagline const taglineHtml = data.tagline
? `<div class="center tagline">${esc(data.tagline)}</div>` ? `<div class="center tagline">${esc(data.tagline)}</div>`
: ""; : "";
const headerHtml = data.header
? `<div class="center header-note">${esc(data.header)}</div>`
: "";
// Paper width: 58 mm or 80 mm. Narrower paper gets a slightly smaller base
// font so lines don't wrap awkwardly.
const widthMm = data.paperWidthMm === 58 ? 58 : 80;
const baseFontPt = widthMm === 58 ? 10 : 11.5;
const wrapPadMm = widthMm === 58 ? "3mm 3mm 5mm" : "4mm 4.5mm 6mm";
const fontUrl = vazirmatnFontUrl(); const fontUrl = vazirmatnFontUrl();
@@ -132,18 +152,18 @@ export function buildThermalDocument(data: ThermalSlipData): string {
} }
/* ── Page ─────────────────────────────────────────────────── */ /* ── Page ─────────────────────────────────────────────────── */
@page { @page {
/* 80 mm wide; height tracks the content — no blank tail */ /* width tracks the configured paper; height tracks content — no blank tail */
size: 80mm auto; size: ${widthMm}mm auto;
margin: 0; margin: 0;
} }
/* ── Reset ────────────────────────────────────────────────── */ /* ── Reset ────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Root ─────────────────────────────────────────────────── */ /* ── Root ─────────────────────────────────────────────────── */
html, body { html, body {
width: 80mm; width: ${widthMm}mm;
direction: ${dir}; direction: ${dir};
font-family: 'Vazirmatn', 'Tahoma', 'Arial', sans-serif; font-family: 'Vazirmatn', 'Tahoma', 'Arial', sans-serif;
font-size: 11.5pt; font-size: ${baseFontPt}pt;
font-weight: 500; font-weight: 500;
line-height: 1.55; line-height: 1.55;
color: #000; color: #000;
@@ -152,7 +172,7 @@ html, body {
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
/* ── Wrapper ──────────────────────────────────────────────── */ /* ── Wrapper ──────────────────────────────────────────────── */
.wrap { padding: 4mm 4.5mm 6mm; } .wrap { padding: ${wrapPadMm}; }
/* ── Branding header ──────────────────────────────────────── */ /* ── Branding header ──────────────────────────────────────── */
.logo { .logo {
display: block; display: block;
@@ -174,6 +194,13 @@ html, body {
color: #444; color: #444;
margin-top: 0.5mm; margin-top: 0.5mm;
} }
.header-note {
font-size: 9pt;
font-weight: 600;
color: #222;
margin-top: 1mm;
white-space: pre-line;
}
.doc-title { .doc-title {
text-align: center; text-align: center;
font-size: 10pt; font-size: 10pt;
@@ -233,6 +260,7 @@ hr.solid { border: none; border-top: 1px solid #000; margin: 2.5mm 0; }
${logoHtml} ${logoHtml}
<div class="cafe-name">${esc(data.cafeName)}</div> <div class="cafe-name">${esc(data.cafeName)}</div>
${taglineHtml} ${taglineHtml}
${headerHtml}
<div class="doc-title">${esc(data.title)}</div> <div class="doc-title">${esc(data.title)}</div>
<div class="date">${esc(data.date)}</div> <div class="date">${esc(data.date)}</div>
${data.metaRow ? `<div class="meta">${esc(data.metaRow)}</div>` : ""} ${data.metaRow ? `<div class="meta">${esc(data.metaRow)}</div>` : ""}
+3 -1
View File
@@ -4,5 +4,7 @@ import { routing } from "./i18n/routing";
export default createMiddleware(routing); export default createMiddleware(routing);
export const config = { export const config = {
matcher: ["/", "/(fa|ar|en)/:path*"], // Match every path so un-prefixed URLs get redirected to the default locale (fa).
// Exclude API routes, Next internals, the guest QR menu (/q), and static files.
matcher: ["/((?!api|_next|_vercel|q|.*\\..*).*)"],
}; };
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"cap:sync": "cap sync android", "cap:sync": "cap sync android",
+2 -2
View File
@@ -135,7 +135,7 @@
"proPriceNote": "/ month", "proPriceNote": "/ month",
"proDesc": "For growing cafes that need professional features.", "proDesc": "For growing cafes that need professional features.",
"ctaPro": "Get Pro", "ctaPro": "Get Pro",
"p1": "1 branch — unlimited orders", "p1": "3 branches — unlimited orders",
"p2": "3 POS terminals", "p2": "3 POS terminals",
"p3": "Full POS & kitchen KDS", "p3": "Full POS & kitchen KDS",
"p4": "Full analytics & reports", "p4": "Full analytics & reports",
@@ -147,7 +147,7 @@
"businessPriceNote": "/ month", "businessPriceNote": "/ month",
"businessDesc": "For restaurants and multi-branch chains.", "businessDesc": "For restaurants and multi-branch chains.",
"ctaBusiness": "Get Business", "ctaBusiness": "Get Business",
"b1": "Up to 5 branches — unlimited orders", "b1": "Unlimited branches — unlimited orders",
"b2": "Unlimited terminals", "b2": "Unlimited terminals",
"b3": "HR module & shift management", "b3": "HR module & shift management",
"b4": "Delivery platform integration", "b4": "Delivery platform integration",
+2 -2
View File
@@ -135,7 +135,7 @@
"proPriceNote": "تومان / ماه", "proPriceNote": "تومان / ماه",
"proDesc": "برای کافه‌های در حال رشد با نیاز به امکانات حرفه‌ای.", "proDesc": "برای کافه‌های در حال رشد با نیاز به امکانات حرفه‌ای.",
"ctaPro": "خرید پرو", "ctaPro": "خرید پرو",
"p1": "۱ شعبه — سفارش نامحدود", "p1": "۳ شعبه — سفارش نامحدود",
"p2": "۳ ترمینال صندوق", "p2": "۳ ترمینال صندوق",
"p3": "POS کامل و آشپزخانه KDS", "p3": "POS کامل و آشپزخانه KDS",
"p4": "گزارش‌های کامل و تحلیلی", "p4": "گزارش‌های کامل و تحلیلی",
@@ -147,7 +147,7 @@
"businessPriceNote": "تومان / ماه", "businessPriceNote": "تومان / ماه",
"businessDesc": "برای رستوران‌ها و زنجیره‌های چند شعبه‌ای.", "businessDesc": "برای رستوران‌ها و زنجیره‌های چند شعبه‌ای.",
"ctaBusiness": "خرید بیزنس", "ctaBusiness": "خرید بیزنس",
"b1": "تا ۵ شعبه — سفارش نامحدود", "b1": "شعبه نامحدود — سفارش نامحدود",
"b2": "ترمینال نامحدود", "b2": "ترمینال نامحدود",
"b3": "ماژول منابع انسانی و شیفت", "b3": "ماژول منابع انسانی و شیفت",
"b4": "یکپارچگی اسنپ‌فود / پیک", "b4": "یکپارچگی اسنپ‌فود / پیک",
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3010", "dev": "next dev -p 3010",
"build": "next build", "build": "next build --webpack",
"start": "next start -p 3010", "start": "next start -p 3010",
"lint": "next lint" "lint": "next lint"
}, },