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

This commit is contained in:
soroush.asadi
2026-05-31 11:06:24 +03:30
parent 51e422272d
commit 345ae0a4b5
69 changed files with 11964 additions and 152 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
+21 -22
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/packages served from local Nexus at 171.22.25.73: # All images/packages served from Nexus at mirror.soroushasadi.com:
# Docker images → 171.22.25.73:8087 (docker-group connector: Docker Hub + MCR) # Docker images → mirror.soroushasadi.com (docker-group: Docker Hub + MCR)
# NuGet → http://171.22.25.73:8081/repository/nuget-group/ # NuGet → https://mirror.soroushasadi.com/repository/nuget-group/
# npm → http://171.22.25.73:8081/repository/npm-group/ # npm → https://mirror.soroushasadi.com/repository/npm-group/
# #
# The runner host is 171.22.25.73, so Nexus is always reachable directly. # Docker daemon: merge docker/daemon-registry-mirror.example.json into daemon.json
# Daemon must have: "insecure-registries": ["171.22.25.73:8087"]
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
jobs: jobs:
@@ -32,12 +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:8087/dotnet/sdk:10.0 image: mirror.soroushasadi.com/dotnet/sdk:10.0
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
services: services:
postgres: postgres:
image: 171.22.25.73:8087/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
@@ -48,7 +47,7 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
redis: redis:
image: 171.22.25.73:8087/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,9 +73,9 @@ jobs:
<packageSources> <packageSources>
<clear /> <clear />
<add key="nexus" <add key="nexus"
value="http://171.22.25.73:8081/repository/nuget-group/index.json" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3" protocolVersion="3"
allowInsecureConnections="true" /> />
</packageSources> </packageSources>
</configuration> </configuration>
EOF EOF
@@ -99,7 +98,7 @@ 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:8087/dotnet/sdk:10.0 image: mirror.soroushasadi.com/dotnet/sdk:10.0
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
steps: steps:
@@ -122,9 +121,9 @@ jobs:
<packageSources> <packageSources>
<clear /> <clear />
<add key="nexus" <add key="nexus"
value="http://171.22.25.73:8081/repository/nuget-group/index.json" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3" protocolVersion="3"
allowInsecureConnections="true" /> />
</packageSources> </packageSources>
</configuration> </configuration>
EOF EOF
@@ -141,7 +140,7 @@ jobs:
name: "CI · Dashboard (tsc)" name: "CI · Dashboard (tsc)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:8087/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
steps: steps:
@@ -159,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://171.22.25.73:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
- name: TypeScript check - name: TypeScript check
working-directory: web/dashboard working-directory: web/dashboard
@@ -171,7 +170,7 @@ 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:8087/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
steps: steps:
@@ -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://171.22.25.73:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
- name: TypeScript check - name: TypeScript check
working-directory: web/admin working-directory: web/admin
@@ -201,7 +200,7 @@ jobs:
name: "CI · Website (tsc)" name: "CI · Website (tsc)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:8087/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
steps: steps:
@@ -219,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://171.22.25.73:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
- name: TypeScript check - name: TypeScript check
working-directory: web/website working-directory: web/website
@@ -231,7 +230,7 @@ jobs:
name: "CI · Koja (tsc)" name: "CI · Koja (tsc)"
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 171.22.25.73:8087/node:20-alpine image: mirror.soroushasadi.com/node:20-alpine
options: >- options: >-
--add-host=gitea:host-gateway --add-host=gitea:host-gateway
steps: steps:
@@ -249,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://171.22.25.73:8081/repository/npm-group/ run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
- name: TypeScript check - name: TypeScript check
working-directory: web/koja working-directory: web/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:
+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:-171.22.25.73:8087/dotnet/sdk:10.0} DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mirror.soroushasadi.com/dotnet/sdk:10.0}
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-171.22.25.73:8087/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:-171.22.25.73:8087/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-http://171.22.25.73:8081/repository/npm-group/} 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.
+15 -16
View File
@@ -1,12 +1,11 @@
# Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja) # Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja)
# #
# All images/packages served from local Nexus at 171.22.25.73: # All images/packages served from Nexus at mirror.soroushasadi.com:
# Docker images → 171.22.25.73:8087 (docker-group connector: proxies Docker Hub + MCR) # Docker images → mirror.soroushasadi.com (docker-group: Docker Hub + MCR)
# NuGet → http://171.22.25.73:8081/repository/nuget-group/ # NuGet → https://mirror.soroushasadi.com/repository/nuget-group/
# npm → http://171.22.25.73:8081/repository/npm-group/ # npm → https://mirror.soroushasadi.com/repository/npm-group/
# #
# Docker Desktop: add "insecure-registries": ["171.22.25.73:8087"] to daemon.json # Docker Desktop: merge docker/daemon-registry-mirror.example.json into daemon.json
# (8087 is the Nexus Docker connector port; it serves images at the root path)
# #
# Local dev: # Local dev:
# cp .env.example .env # cp .env.example .env
@@ -26,7 +25,7 @@
services: services:
postgres: postgres:
image: ${POSTGRES_IMAGE:-171.22.25.73:8087/postgres:16-alpine} image: ${POSTGRES_IMAGE:-mirror.soroushasadi.com/postgres:16-alpine}
container_name: meezi-db container_name: meezi-db
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -44,7 +43,7 @@ services:
retries: 10 retries: 10
redis: redis:
image: ${REDIS_IMAGE:-171.22.25.73:8087/redis:7-alpine} image: ${REDIS_IMAGE:-mirror.soroushasadi.com/redis:7-alpine}
container_name: meezi-redis container_name: meezi-redis
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -65,8 +64,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-171.22.25.73:8087/dotnet/sdk:10.0} DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mirror.soroushasadi.com/dotnet/sdk:10.0}
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-171.22.25.73:8087/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:
@@ -111,8 +110,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:8087/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-http://171.22.25.73:8081/repository/npm-group/} 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
@@ -132,8 +131,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:8087/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-http://171.22.25.73:8081/repository/npm-group/} 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
@@ -156,8 +155,8 @@ services:
extra_hosts: extra_hosts:
- "mirror:host-gateway" - "mirror:host-gateway"
args: args:
NODE_IMAGE: ${NODE_IMAGE:-171.22.25.73:8087/node:20-alpine} NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
NPM_REGISTRY: ${NPM_REGISTRY:-http://171.22.25.73:8081/repository/npm-group/} 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=171.22.25.73:8087/dotnet/sdk:10.0 ARG DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0
ARG DOTNET_ASPNET_IMAGE=171.22.25.73:8087/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 (171.22.25.73:8081) # 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=171.22.25.73:8087/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=http://171.22.25.73:8081/repository/npm-group/ ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
# Install deps then ensure Alpine (musl) SWC binary is present # 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=171.22.25.73:8087/dotnet/sdk:10.0 ARG DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0
ARG DOTNET_ASPNET_IMAGE=171.22.25.73:8087/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 (171.22.25.73:8081) # 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 -4
View File
@@ -1,8 +1,5 @@
{ {
"insecure-registries": [
"171.22.25.73:8087"
],
"registry-mirrors": [ "registry-mirrors": [
"http://171.22.25.73:8087" "https://mirror.soroushasadi.com"
] ]
} }
+2 -2
View File
@@ -1,9 +1,9 @@
ARG NODE_IMAGE=171.22.25.73:8087/node:20-alpine ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
FROM ${NODE_IMAGE} AS deps FROM ${NODE_IMAGE} AS deps
WORKDIR /app WORKDIR /app
COPY web/koja/package*.json ./ COPY web/koja/package*.json ./
ARG NPM_REGISTRY=http://171.22.25.73:8081/repository/npm-group/ ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} 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=171.22.25.73:8087/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=http://171.22.25.73:8081/repository/npm-group/ ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} 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=171.22.25.73:8087/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=http://171.22.25.73:8081/repository/npm-group/ ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
# Install deps then ensure Alpine (musl) SWC binary is present # 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 -4
View File
@@ -1,12 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- NuGet config for Docker builds — routes restores through local Nexus mirror. --> <!-- NuGet config for Docker builds — routes restores through Nexus at mirror.soroushasadi.com -->
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<add key="nexus" <add key="nexus"
value="http://171.22.25.73:8081/repository/nuget-group/index.json" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3" protocolVersion="3" />
allowInsecureConnections="true" />
</packageSources> </packageSources>
<config> <config>
<add key="http_retry_count" value="8" /> <add key="http_retry_count" value="8" />
+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)));
}
}
@@ -90,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)
@@ -178,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");
+27 -1
View File
@@ -56,6 +56,29 @@
"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": "طي الشريط الجانبي", "collapseSidebar": "طي الشريط الجانبي",
@@ -163,6 +186,8 @@
"cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.", "cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.",
"cancelOrderSuccess": "تم إلغاء الطلب", "cancelOrderSuccess": "تم إلغاء الطلب",
"cancelOrderError": "تعذّر إلغاء الطلب", "cancelOrderError": "تعذّر إلغاء الطلب",
"cancelReasonPlaceholder": "سبب الإلغاء (اختياري)",
"cancelOrderHasPayments": "استرجع المدفوعات المسجّلة أولاً ثم ألغِ الطلب",
"itemsCount": "صنف", "itemsCount": "صنف",
"applyCoupon": "تطبيق القسيمة", "applyCoupon": "تطبيق القسيمة",
"couponPlaceholder": "رمز القسيمة", "couponPlaceholder": "رمز القسيمة",
@@ -360,7 +385,8 @@
"tabs": { "tabs": {
"attendance": "الحضور", "attendance": "الحضور",
"leave": "الإجازة", "leave": "الإجازة",
"payroll": "الرواتب" "payroll": "الرواتب",
"access": "صلاحيات الفروع"
}, },
"myAttendance": "حضوري", "myAttendance": "حضوري",
"clockIn": "تسجيل دخول", "clockIn": "تسجيل دخول",
+27 -1
View File
@@ -67,6 +67,29 @@
"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", "collapseSidebar": "Collapse sidebar",
@@ -182,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",
@@ -379,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",
+27 -1
View File
@@ -67,6 +67,29 @@
"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": "جمع کردن نوار کناری", "collapseSidebar": "جمع کردن نوار کناری",
@@ -182,6 +205,8 @@
"cancelOrderConfirm": "مشتری بدون پرداخت رفته است؟ سفارش لغو می‌شود و میز آزاد می‌شود.", "cancelOrderConfirm": "مشتری بدون پرداخت رفته است؟ سفارش لغو می‌شود و میز آزاد می‌شود.",
"cancelOrderSuccess": "سفارش لغو شد", "cancelOrderSuccess": "سفارش لغو شد",
"cancelOrderError": "لغو سفارش ناموفق بود", "cancelOrderError": "لغو سفارش ناموفق بود",
"cancelReasonPlaceholder": "دلیل لغو (اختیاری)",
"cancelOrderHasPayments": "ابتدا پرداخت‌های ثبت‌شده را بازگردانید، سپس سفارش را لغو کنید",
"itemsCount": "قلم", "itemsCount": "قلم",
"applyCoupon": "اعمال کوپن", "applyCoupon": "اعمال کوپن",
"couponPlaceholder": "کد کوپن", "couponPlaceholder": "کد کوپن",
@@ -379,7 +404,8 @@
"tabs": { "tabs": {
"attendance": "حضور و غیاب", "attendance": "حضور و غیاب",
"leave": "مرخصی", "leave": "مرخصی",
"payroll": "حقوق" "payroll": "حقوق",
"access": "دسترسی شعب"
}, },
"myAttendance": "حضور من", "myAttendance": "حضور من",
"clockIn": "ورود", "clockIn": "ورود",
@@ -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>
); );
@@ -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>
); );
} }
+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>
);
}
@@ -5,6 +5,7 @@ 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,
@@ -102,6 +103,7 @@ function NavGroupSection({
pathname, pathname,
role, role,
branchId, branchId,
permissions,
tItem, tItem,
collapsed, collapsed,
}: { }: {
@@ -112,11 +114,12 @@ 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; 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;
@@ -198,6 +201,7 @@ 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 [collapsed, setCollapsed] = useState<boolean>(readStoredCollapsed);
@@ -229,9 +233,9 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
() => () =>
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) => {
@@ -332,6 +336,7 @@ 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} collapsed={collapsed}
/> />
@@ -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";
@@ -54,6 +54,7 @@ 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 printSettingsBranchId = receiptOrder?.branchId ?? branchId ?? null;
const [lastPaidOrderId, setLastPaidOrderId] = useState<string | null>(null); const [lastPaidOrderId, setLastPaidOrderId] = useState<string | null>(null);
@@ -167,6 +168,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
useEffect(() => { useEffect(() => {
setLoyaltyRedeem(0); setLoyaltyRedeem(0);
setCancelReason("");
}, [selected?.id]); }, [selected?.id]);
useEffect(() => { useEffect(() => {
@@ -246,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"));
}, },
}); });
@@ -596,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"
@@ -609,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")}
+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);
}
+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|.*\\..*).*)"],
}; };