Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 345ae0a4b5 | |||
| 51e422272d | |||
| 2850ed8ed7 | |||
| 86bbefb9e3 | |||
| 8ca2cae988 | |||
| 09c55669ca | |||
| 639573dfde | |||
| b6e4f83035 | |||
| e8cd6d3282 | |||
| 62bd7a12f5 |
+2
-2
@@ -81,5 +81,5 @@ KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F433346
|
|||||||
SNAPPFOOD_WEBHOOK_SECRET=change-me-snappfood-secret
|
SNAPPFOOD_WEBHOOK_SECRET=change-me-snappfood-secret
|
||||||
|
|
||||||
# ── Docker image overrides (if direct MCR pull fails) ────────────────────────
|
# ── Docker image overrides (if direct MCR pull fails) ────────────────────────
|
||||||
# DOTNET_SDK_IMAGE=171.22.25.73:5002/dotnet/sdk:10.0
|
# DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0
|
||||||
# DOTNET_ASPNET_IMAGE=171.22.25.73:5002/dotnet/aspnet:10.0
|
# DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0
|
||||||
|
|||||||
+25
-29
@@ -17,13 +17,12 @@ concurrency:
|
|||||||
# ubuntu-latest:docker://node:20-alpine ← CI jobs run in real Docker containers
|
# ubuntu-latest:docker://node:20-alpine ← CI jobs run in real Docker containers
|
||||||
# self-hosted:host ← deploy runs directly on the server
|
# self-hosted:host ← deploy runs directly on the server
|
||||||
#
|
#
|
||||||
# All images are pulled from local Nexus mirrors (fast, no internet):
|
# All images/packages served from Nexus at mirror.soroushasadi.com:
|
||||||
# Docker Hub → http://171.22.25.73:5000 (docker-hub-proxy repo)
|
# Docker images → mirror.soroushasadi.com (docker-group: Docker Hub + MCR)
|
||||||
# MCR → http://171.22.25.73:5002 (mcr-proxy repo)
|
# NuGet → https://mirror.soroushasadi.com/repository/nuget-group/
|
||||||
|
# npm → https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
#
|
#
|
||||||
# mirror hostname → host-gateway (docker bridge IP 172.17.0.1) — used for:
|
# Docker daemon: merge docker/daemon-registry-mirror.example.json into daemon.json
|
||||||
# NuGet → http://mirror:8081/repository/nuget-group/
|
|
||||||
# npm → http://mirror:8081/repository/npm-group/
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -32,13 +31,12 @@ jobs:
|
|||||||
name: "CI · API (dotnet build + test)"
|
name: "CI · API (dotnet build + test)"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: 171.22.25.73:5002/dotnet/sdk:10.0
|
image: mirror.soroushasadi.com/dotnet/sdk:10.0
|
||||||
options: >-
|
options: >-
|
||||||
--add-host=gitea:host-gateway
|
--add-host=gitea:host-gateway
|
||||||
--add-host=mirror:host-gateway
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: docker-mirror.liara.ir/library/postgres:16-alpine
|
image: mirror.soroushasadi.com/postgres:16-alpine
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: meezi_test
|
POSTGRES_DB: meezi_test
|
||||||
POSTGRES_USER: meezi
|
POSTGRES_USER: meezi
|
||||||
@@ -49,7 +47,7 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
redis:
|
redis:
|
||||||
image: docker-mirror.liara.ir/library/redis:7-alpine
|
image: mirror.soroushasadi.com/redis:7-alpine
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "redis-cli ping"
|
--health-cmd "redis-cli ping"
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
@@ -74,8 +72,10 @@ jobs:
|
|||||||
<configuration>
|
<configuration>
|
||||||
<packageSources>
|
<packageSources>
|
||||||
<clear />
|
<clear />
|
||||||
<add key="nexus" value="http://mirror:8081/repository/nuget-group/index.json"
|
<add key="nexus"
|
||||||
protocolVersion="3" allowInsecureConnections="true" />
|
value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
|
||||||
|
protocolVersion="3"
|
||||||
|
/>
|
||||||
</packageSources>
|
</packageSources>
|
||||||
</configuration>
|
</configuration>
|
||||||
EOF
|
EOF
|
||||||
@@ -98,10 +98,9 @@ jobs:
|
|||||||
name: "CI · Admin API (dotnet build)"
|
name: "CI · Admin API (dotnet build)"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: 171.22.25.73:5002/dotnet/sdk:10.0
|
image: mirror.soroushasadi.com/dotnet/sdk:10.0
|
||||||
options: >-
|
options: >-
|
||||||
--add-host=gitea:host-gateway
|
--add-host=gitea:host-gateway
|
||||||
--add-host=mirror:host-gateway
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
env:
|
env:
|
||||||
@@ -121,8 +120,10 @@ jobs:
|
|||||||
<configuration>
|
<configuration>
|
||||||
<packageSources>
|
<packageSources>
|
||||||
<clear />
|
<clear />
|
||||||
<add key="nexus" value="http://mirror:8081/repository/nuget-group/index.json"
|
<add key="nexus"
|
||||||
protocolVersion="3" allowInsecureConnections="true" />
|
value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
|
||||||
|
protocolVersion="3"
|
||||||
|
/>
|
||||||
</packageSources>
|
</packageSources>
|
||||||
</configuration>
|
</configuration>
|
||||||
EOF
|
EOF
|
||||||
@@ -139,10 +140,9 @@ jobs:
|
|||||||
name: "CI · Dashboard (tsc)"
|
name: "CI · Dashboard (tsc)"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: 171.22.25.73:5000/library/node:20-alpine
|
image: mirror.soroushasadi.com/node:20-alpine
|
||||||
options: >-
|
options: >-
|
||||||
--add-host=gitea:host-gateway
|
--add-host=gitea:host-gateway
|
||||||
--add-host=mirror:host-gateway
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
env:
|
env:
|
||||||
@@ -158,7 +158,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: web/dashboard
|
working-directory: web/dashboard
|
||||||
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/
|
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
|
|
||||||
- name: TypeScript check
|
- name: TypeScript check
|
||||||
working-directory: web/dashboard
|
working-directory: web/dashboard
|
||||||
@@ -170,10 +170,9 @@ jobs:
|
|||||||
name: "CI · Admin Web (tsc)"
|
name: "CI · Admin Web (tsc)"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: 171.22.25.73:5000/library/node:20-alpine
|
image: mirror.soroushasadi.com/node:20-alpine
|
||||||
options: >-
|
options: >-
|
||||||
--add-host=gitea:host-gateway
|
--add-host=gitea:host-gateway
|
||||||
--add-host=mirror:host-gateway
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
env:
|
env:
|
||||||
@@ -189,7 +188,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: web/admin
|
working-directory: web/admin
|
||||||
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/
|
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
|
|
||||||
- name: TypeScript check
|
- name: TypeScript check
|
||||||
working-directory: web/admin
|
working-directory: web/admin
|
||||||
@@ -201,10 +200,9 @@ jobs:
|
|||||||
name: "CI · Website (tsc)"
|
name: "CI · Website (tsc)"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: 171.22.25.73:5000/library/node:20-alpine
|
image: mirror.soroushasadi.com/node:20-alpine
|
||||||
options: >-
|
options: >-
|
||||||
--add-host=gitea:host-gateway
|
--add-host=gitea:host-gateway
|
||||||
--add-host=mirror:host-gateway
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
env:
|
env:
|
||||||
@@ -220,7 +218,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: web/website
|
working-directory: web/website
|
||||||
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/
|
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
|
|
||||||
- name: TypeScript check
|
- name: TypeScript check
|
||||||
working-directory: web/website
|
working-directory: web/website
|
||||||
@@ -232,10 +230,9 @@ jobs:
|
|||||||
name: "CI · Koja (tsc)"
|
name: "CI · Koja (tsc)"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: 171.22.25.73:5000/library/node:20-alpine
|
image: mirror.soroushasadi.com/node:20-alpine
|
||||||
options: >-
|
options: >-
|
||||||
--add-host=gitea:host-gateway
|
--add-host=gitea:host-gateway
|
||||||
--add-host=mirror:host-gateway
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
env:
|
env:
|
||||||
@@ -251,7 +248,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: web/koja
|
working-directory: web/koja
|
||||||
run: npm install --legacy-peer-deps --ignore-scripts --registry http://mirror:8081/repository/npm-group/
|
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
|
|
||||||
- name: TypeScript check
|
- name: TypeScript check
|
||||||
working-directory: web/koja
|
working-directory: web/koja
|
||||||
@@ -316,7 +313,6 @@ jobs:
|
|||||||
- name: Start main services
|
- name: Start main services
|
||||||
run: |
|
run: |
|
||||||
docker compose up -d \
|
docker compose up -d \
|
||||||
--remove-orphans \
|
|
||||||
--no-deps \
|
--no-deps \
|
||||||
postgres redis api web website koja
|
postgres redis api web website koja
|
||||||
|
|
||||||
|
|||||||
@@ -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 ~5–10 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:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<PackageVersion Include="QuestPDF" Version="2024.12.3" />
|
<PackageVersion Include="QuestPDF" Version="2024.12.3" />
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
<PackageVersion Include="Kavenegar" Version="1.2.4" />
|
||||||
<PackageVersion Include="StackExchange.Redis" Version="2.8.16" />
|
<PackageVersion Include="StackExchange.Redis" Version="2.8.16" />
|
||||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
|
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr-mirror.liara.ir/dotnet/sdk:10.0}
|
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mirror.soroushasadi.com/dotnet/sdk:10.0}
|
||||||
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0}
|
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mirror.soroushasadi.com/dotnet/aspnet:10.0}
|
||||||
container_name: meezi-admin-api
|
container_name: meezi-admin-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -52,8 +52,8 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
||||||
NEXT_PUBLIC_ADMIN_API_URL: ${NEXT_PUBLIC_ADMIN_API_URL:-http://localhost:5081}
|
NEXT_PUBLIC_ADMIN_API_URL: ${NEXT_PUBLIC_ADMIN_API_URL:-http://localhost:5081}
|
||||||
container_name: meezi-admin-web
|
container_name: meezi-admin-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
# ./mirrors/nexus/provision.sh # creates all proxy repos + enables anon access
|
# ./mirrors/nexus/provision.sh # creates all proxy repos + enables anon access
|
||||||
#
|
#
|
||||||
# Endpoints (after provisioning):
|
# Endpoints (after provisioning):
|
||||||
# UI → http://SERVER_IP:8081 (admin / see provision.sh output)
|
# UI → https://mirror.soroushasadi.com/ (admin / see provision.sh output)
|
||||||
# NuGet → http://SERVER_IP:8081/repository/nuget-proxy/index.json
|
# NuGet → https://mirror.soroushasadi.com/repository/nuget-group/index.json
|
||||||
# npm → http://SERVER_IP:8081/repository/npm-proxy/
|
# npm → https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
# Docker → http://SERVER_IP:5000 (add to /etc/docker/daemon.json)
|
# Docker → https://mirror.soroushasadi.com (add to daemon.json registry-mirrors)
|
||||||
#
|
#
|
||||||
# Memory: needs ~2 GB JVM heap — recommended on a server with 4 GB+ total RAM.
|
# Memory: needs ~2 GB JVM heap — recommended on a server with 4 GB+ total RAM.
|
||||||
# Adjust INSTALL4J_ADD_VM_PARAMS below if your server has more/less RAM.
|
# Adjust INSTALL4J_ADD_VM_PARAMS below if your server has more/less RAM.
|
||||||
|
|||||||
+17
-10
@@ -1,5 +1,12 @@
|
|||||||
# Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja)
|
# Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja)
|
||||||
#
|
#
|
||||||
|
# All images/packages served from Nexus at mirror.soroushasadi.com:
|
||||||
|
# Docker images → mirror.soroushasadi.com (docker-group: Docker Hub + MCR)
|
||||||
|
# NuGet → https://mirror.soroushasadi.com/repository/nuget-group/
|
||||||
|
# npm → https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
|
#
|
||||||
|
# Docker Desktop: merge docker/daemon-registry-mirror.example.json into daemon.json
|
||||||
|
#
|
||||||
# Local dev:
|
# Local dev:
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
# docker compose up -d --build
|
# docker compose up -d --build
|
||||||
@@ -18,7 +25,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: ${POSTGRES_IMAGE:-docker-mirror.liara.ir/library/postgres:16-alpine}
|
image: ${POSTGRES_IMAGE:-mirror.soroushasadi.com/postgres:16-alpine}
|
||||||
container_name: meezi-db
|
container_name: meezi-db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -36,7 +43,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: ${REDIS_IMAGE:-docker-mirror.liara.ir/library/redis:7-alpine}
|
image: ${REDIS_IMAGE:-mirror.soroushasadi.com/redis:7-alpine}
|
||||||
container_name: meezi-redis
|
container_name: meezi-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -57,8 +64,8 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr-mirror.liara.ir/dotnet/sdk:10.0}
|
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mirror.soroushasadi.com/dotnet/sdk:10.0}
|
||||||
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mcr-mirror.liara.ir/dotnet/aspnet:10.0}
|
DOTNET_ASPNET_IMAGE: ${DOTNET_ASPNET_IMAGE:-mirror.soroushasadi.com/dotnet/aspnet:10.0}
|
||||||
container_name: meezi-api
|
container_name: meezi-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -103,8 +110,8 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
||||||
container_name: meezi-web
|
container_name: meezi-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -124,8 +131,8 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
||||||
MEEZI_API_URL: http://api:8080
|
MEEZI_API_URL: http://api:8080
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
|
||||||
container_name: meezi-website
|
container_name: meezi-website
|
||||||
@@ -148,8 +155,8 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "mirror:host-gateway"
|
- "mirror:host-gateway"
|
||||||
args:
|
args:
|
||||||
NODE_IMAGE: ${NODE_IMAGE:-docker-mirror.liara.ir/library/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://package-mirror.liara.ir/repository/npm/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103}
|
||||||
container_name: meezi-koja
|
container_name: meezi-koja
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
ARG DOTNET_SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0
|
ARG DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0
|
||||||
ARG DOTNET_ASPNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0
|
ARG DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0
|
||||||
|
|
||||||
FROM ${DOTNET_SDK_IMAGE} AS build
|
FROM ${DOTNET_SDK_IMAGE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY global.json Directory.Build.props Directory.Packages.props ./
|
COPY global.json Directory.Build.props Directory.Packages.props ./
|
||||||
# nuget.docker.config points to local Nexus mirror (mirror:8081 via extra_hosts in compose)
|
# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
|
||||||
COPY nuget.docker.config ./nuget.config
|
COPY nuget.docker.config ./nuget.config
|
||||||
|
|
||||||
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
|
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine
|
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/admin/package*.json ./
|
COPY web/admin/package*.json ./
|
||||||
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
# Install deps then ensure Alpine (musl) SWC binary is present
|
# Install deps then ensure Alpine (musl) SWC binary is present
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
|
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
|
||||||
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
|
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
ARG DOTNET_SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0
|
ARG DOTNET_SDK_IMAGE=mirror.soroushasadi.com/dotnet/sdk:10.0
|
||||||
ARG DOTNET_ASPNET_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0
|
ARG DOTNET_ASPNET_IMAGE=mirror.soroushasadi.com/dotnet/aspnet:10.0
|
||||||
|
|
||||||
FROM ${DOTNET_SDK_IMAGE} AS build
|
FROM ${DOTNET_SDK_IMAGE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY global.json Directory.Build.props Directory.Packages.props ./
|
COPY global.json Directory.Build.props Directory.Packages.props ./
|
||||||
# nuget.docker.config points to local Nexus mirror (mirror:8081 via extra_hosts in compose)
|
# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
|
||||||
COPY nuget.docker.config ./nuget.config
|
COPY nuget.docker.config ./nuget.config
|
||||||
|
|
||||||
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
|
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"registry-mirrors": [
|
"registry-mirrors": [
|
||||||
"https://docker.iranrepo.ir",
|
"https://mirror.soroushasadi.com"
|
||||||
"https://registry.docker.ir"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine
|
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/koja/package*.json ./
|
COPY web/koja/package*.json ./
|
||||||
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
|
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine
|
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/dashboard/package*.json ./
|
COPY web/dashboard/package*.json ./
|
||||||
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
|
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine
|
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/website/package*.json ./
|
COPY web/website/package*.json ./
|
||||||
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
# Install deps then ensure Alpine (musl) SWC binary is present
|
# Install deps then ensure Alpine (musl) SWC binary is present
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
|
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
|
||||||
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
|
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -176,12 +176,12 @@ echo "════════════════════════
|
|||||||
echo "🎉 Nexus provisioned!"
|
echo "🎉 Nexus provisioned!"
|
||||||
echo "═══════════════════════════════════════════════════════════════"
|
echo "═══════════════════════════════════════════════════════════════"
|
||||||
echo ""
|
echo ""
|
||||||
echo " UI → http://$(hostname -I | awk '{print $1}'):8081"
|
echo " UI → https://mirror.soroushasadi.com/"
|
||||||
echo " admin / $ADMIN_PASS"
|
echo " admin / $ADMIN_PASS"
|
||||||
echo ""
|
echo ""
|
||||||
echo " NuGet → http://$(hostname -I | awk '{print $1}'):8081/repository/nuget-proxy/index.json"
|
echo " NuGet → https://mirror.soroushasadi.com/repository/nuget-group/index.json"
|
||||||
echo " npm → http://$(hostname -I | awk '{print $1}'):8081/repository/npm-proxy/"
|
echo " npm → https://mirror.soroushasadi.com/repository/npm-group/"
|
||||||
echo " Docker → http://$(hostname -I | awk '{print $1}'):8083 ← upstream: $DOCKER_UPSTREAM"
|
echo " Docker → https://mirror.soroushasadi.com ← upstream: $DOCKER_UPSTREAM"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -z "$DOCKER_USER" ]; then
|
if [ -z "$DOCKER_USER" ]; then
|
||||||
echo " 💡 To switch Docker upstream to Liara mirror (faster in Iran):"
|
echo " 💡 To switch Docker upstream to Liara mirror (faster in Iran):"
|
||||||
@@ -194,7 +194,7 @@ if [ -z "$DOCKER_USER" ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
echo "To activate Docker Hub mirror on this server:"
|
echo "To activate Docker Hub mirror on this server:"
|
||||||
echo " Edit /etc/docker/daemon.json:"
|
echo " Merge docker/daemon-registry-mirror.example.json into /etc/docker/daemon.json"
|
||||||
echo ' { "insecure-registries": ["'"$(hostname -I | awk '{print $1}'):8083"'"], "registry-mirrors": ["http://'"$(hostname -I | awk '{print $1}'):8083"'"] }'
|
echo ' { "registry-mirrors": ["https://mirror.soroushasadi.com"] }'
|
||||||
echo " systemctl restart docker"
|
echo " systemctl restart docker"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- NuGet config for Docker builds — routes restores through Liara NuGet mirror. -->
|
<!-- NuGet config for Docker builds — routes restores through Nexus at mirror.soroushasadi.com -->
|
||||||
<configuration>
|
<configuration>
|
||||||
<packageSources>
|
<packageSources>
|
||||||
<clear />
|
<clear />
|
||||||
<add key="liara-nuget" value="https://package-mirror.liara.ir/repository/nuget/index.json"
|
<add key="nexus"
|
||||||
|
value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
|
||||||
protocolVersion="3" />
|
protocolVersion="3" />
|
||||||
</packageSources>
|
</packageSources>
|
||||||
<config>
|
<config>
|
||||||
|
|||||||
+1
-1
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Meezi.API.Models.Auth;
|
using Meezi.API.Models.Auth;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.API.Services;
|
|
||||||
using Meezi.Core.Constants;
|
using Meezi.Core.Constants;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -91,6 +90,27 @@ public class AuthController : ControllerBase
|
|||||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("switch-branch")]
|
||||||
|
[Authorize]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> SwitchBranch([FromBody] SwitchBranchRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||||
|
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var cafeId = User.FindFirstValue(MeeziClaimTypes.CafeId);
|
||||||
|
if (string.IsNullOrEmpty(cafeId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var (success, data, code, message) = await _authService.SwitchBranchAsync(userId, cafeId, request.BranchId, cancellationToken);
|
||||||
|
if (!success)
|
||||||
|
return ErrorResult(code!, message!);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||||
@@ -179,6 +199,8 @@ public class AuthController : ControllerBase
|
|||||||
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
|
"BRANCH_FORBIDDEN" => StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
|
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -27,6 +28,50 @@ public abstract class CafeApiControllerBase : ControllerBase
|
|||||||
new ApiError("OWNER_REQUIRED", "Only the cafe owner can perform this action.")));
|
new ApiError("OWNER_REQUIRED", "Only the cafe owner can perform this action.")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Owner or Manager may act.</summary>
|
||||||
|
protected IActionResult? EnsureManager(ITenantContext tenant)
|
||||||
|
{
|
||||||
|
if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager)
|
||||||
|
return null;
|
||||||
|
return Forbidden("MANAGER_REQUIRED", "Manager access required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The employee acting on their own record, or a manager/owner.</summary>
|
||||||
|
protected IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant)
|
||||||
|
{
|
||||||
|
if (tenant.UserId == employeeId)
|
||||||
|
return null;
|
||||||
|
return EnsureManager(tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gate by an explicit capability from the role→permission matrix.</summary>
|
||||||
|
protected IActionResult? EnsurePermission(ITenantContext tenant, Permission permission)
|
||||||
|
{
|
||||||
|
if (tenant.Role is { } role && RolePermissions.Has(role, permission))
|
||||||
|
return null;
|
||||||
|
return Forbidden("FORBIDDEN", "You do not have permission to perform this action.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strict branch isolation at the controller boundary: a branch-scoped session
|
||||||
|
/// may only touch its own branch. Café-wide sessions (Owner) and sessions with
|
||||||
|
/// no active branch are unrestricted here (DB query filters back this up).
|
||||||
|
/// </summary>
|
||||||
|
protected IActionResult? EnsureBranchAccess(string? routeBranchId, ITenantContext tenant)
|
||||||
|
{
|
||||||
|
if (tenant.Role is { } role && RolePermissions.IsCafeWide(role))
|
||||||
|
return null;
|
||||||
|
if (string.IsNullOrEmpty(tenant.BranchId))
|
||||||
|
return null;
|
||||||
|
if (string.IsNullOrEmpty(routeBranchId) || routeBranchId == tenant.BranchId)
|
||||||
|
return null;
|
||||||
|
return Forbidden("BRANCH_FORBIDDEN", "You do not have access to this branch.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectResult Forbidden(string code, string message) =>
|
||||||
|
StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
new ApiResponse<object>(false, null, new ApiError(code, message)));
|
||||||
|
|
||||||
protected static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation)
|
protected static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation)
|
||||||
{
|
{
|
||||||
var first = validation.Errors.First();
|
var first = validation.Errors.First();
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>());
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
+3203
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3278
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3287
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOrderCancellationFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CancelReason",
|
||||||
|
table: "Orders",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "CancelledAt",
|
||||||
|
table: "Orders",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CancelledByEmployeeId",
|
||||||
|
table: "Orders",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CancelReason",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CancelledAt",
|
||||||
|
table: "Orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CancelledByEmployeeId",
|
||||||
|
table: "Orders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,72 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("Attendances");
|
b.ToTable("Attendances");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.AuditLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(96)
|
||||||
|
.HasColumnType("character varying(96)");
|
||||||
|
|
||||||
|
b.Property<string>("ActorId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ActorName")
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<string>("ActorRole")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("BranchId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CafeId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DetailsJson")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("EntityId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("EntityType")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Summary")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CafeId", "BranchId");
|
||||||
|
|
||||||
|
b.HasIndex("CafeId", "Category");
|
||||||
|
|
||||||
|
b.HasIndex("CafeId", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("AuditLogs");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.Branch", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.Branch", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -884,6 +950,45 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("Employees");
|
b.ToTable("Employees");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("BranchId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CafeId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("EmployeeId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BranchId");
|
||||||
|
|
||||||
|
b.HasIndex("CafeId", "BranchId");
|
||||||
|
|
||||||
|
b.HasIndex("EmployeeId", "BranchId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"DeletedAt\" IS NULL");
|
||||||
|
|
||||||
|
b.ToTable("EmployeeBranchRoles");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -1317,6 +1422,15 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CancelReason")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CancelledAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CancelledByEmployeeId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("CouponId")
|
b.Property<string>("CouponId")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -2424,6 +2538,15 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("Employee");
|
b.Navigation("Employee");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.AuditLog", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Meezi.Core.Entities.Cafe", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CafeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.Branch", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.Branch", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
|
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
|
||||||
@@ -2565,6 +2688,25 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("Cafe");
|
b.Navigation("Cafe");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
|
||||||
|
.WithMany("StaffRoles")
|
||||||
|
.HasForeignKey("BranchId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Meezi.Core.Entities.Employee", "Employee")
|
||||||
|
.WithMany("BranchRoles")
|
||||||
|
.HasForeignKey("EmployeeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Branch");
|
||||||
|
|
||||||
|
b.Navigation("Employee");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Meezi.Core.Entities.Employee", "Employee")
|
b.HasOne("Meezi.Core.Entities.Employee", "Employee")
|
||||||
@@ -3012,6 +3154,8 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.Navigation("Staff");
|
b.Navigation("Staff");
|
||||||
|
|
||||||
|
b.Navigation("StaffRoles");
|
||||||
|
|
||||||
b.Navigation("Tables");
|
b.Navigation("Tables");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3061,6 +3205,8 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("Attendances");
|
b.Navigation("Attendances");
|
||||||
|
|
||||||
|
b.Navigation("BranchRoles");
|
||||||
|
|
||||||
b.Navigation("LeaveRequests");
|
b.Navigation("LeaveRequests");
|
||||||
|
|
||||||
b.Navigation("Orders");
|
b.Navigation("Orders");
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IPlatformCatalogService, PlatformCatalogService>();
|
services.AddScoped<IPlatformCatalogService, PlatformCatalogService>();
|
||||||
services.AddScoped<ISupportTicketService, SupportTicketService>();
|
services.AddScoped<ISupportTicketService, SupportTicketService>();
|
||||||
|
|
||||||
services.AddHttpClient<ISmsService, KavenegarSmsService>();
|
services.AddScoped<ISmsService, KavenegarSmsService>();
|
||||||
services.AddHttpClient<IZarinPalGateway, ZarinPalGateway>();
|
services.AddHttpClient<IZarinPalGateway, ZarinPalGateway>();
|
||||||
services.AddHttpClient<ISnappPayGateway, SnappPayGateway>();
|
services.AddHttpClient<ISnappPayGateway, SnappPayGateway>();
|
||||||
services.AddHttpClient<ITaraPaymentGateway, TaraPaymentGateway>();
|
services.AddHttpClient<ITaraPaymentGateway, TaraPaymentGateway>();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using System.Net.Http.Json;
|
using Kavenegar;
|
||||||
using System.Text.Json.Serialization;
|
using Kavenegar.Exceptions;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Services.Platform;
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace Meezi.Infrastructure.ExternalServices;
|
namespace Meezi.Infrastructure.ExternalServices;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Kavenegar SMS gateway implementation.
|
/// Kavenegar SMS gateway implementation using the official Kavenegar .NET SDK.
|
||||||
/// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back
|
/// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back
|
||||||
/// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.).
|
/// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -21,23 +21,19 @@ public class KavenegarSmsService : ISmsService
|
|||||||
private const string DbKeySender = "integrations.kavenegar.senderNumber";
|
private const string DbKeySender = "integrations.kavenegar.senderNumber";
|
||||||
private const string DbKeyOtpTemplate = "integrations.kavenegar.otpTemplate";
|
private const string DbKeyOtpTemplate = "integrations.kavenegar.otpTemplate";
|
||||||
|
|
||||||
private const string BaseUrl = "https://api.kavenegar.com/v1";
|
|
||||||
private const int MaxBatchSize = 200;
|
private const int MaxBatchSize = 200;
|
||||||
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly IPlatformRuntimeConfig _platform;
|
private readonly IPlatformRuntimeConfig _platform;
|
||||||
private readonly IHostEnvironment _environment;
|
private readonly IHostEnvironment _environment;
|
||||||
private readonly ILogger<KavenegarSmsService> _logger;
|
private readonly ILogger<KavenegarSmsService> _logger;
|
||||||
|
|
||||||
public KavenegarSmsService(
|
public KavenegarSmsService(
|
||||||
HttpClient httpClient,
|
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IPlatformRuntimeConfig platform,
|
IPlatformRuntimeConfig platform,
|
||||||
IHostEnvironment environment,
|
IHostEnvironment environment,
|
||||||
ILogger<KavenegarSmsService> logger)
|
ILogger<KavenegarSmsService> logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient;
|
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_platform = platform;
|
_platform = platform;
|
||||||
_environment = environment;
|
_environment = environment;
|
||||||
@@ -61,16 +57,11 @@ public class KavenegarSmsService : ISmsService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var url = $"{BaseUrl}/{apiKey}/verify/lookup.json";
|
var receptor = NormalizePhone(phone);
|
||||||
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
await RunSdkAsync(apiKey, api =>
|
||||||
{
|
{
|
||||||
["receptor"] = NormalizePhone(phone),
|
api.VerifyLookup(receptor, otp, null, null, template);
|
||||||
["token"] = otp,
|
}, "OTP");
|
||||||
["template"] = template,
|
|
||||||
});
|
|
||||||
|
|
||||||
var response = await _httpClient.PostAsync(url, content, cancellationToken);
|
|
||||||
await EnsureKavenegarSuccessAsync(response, "OTP", cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default)
|
public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default)
|
||||||
@@ -82,11 +73,11 @@ public class KavenegarSmsService : ISmsService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var url = $"{BaseUrl}/{apiKey}/sms/send.json";
|
var receptor = NormalizePhone(phone);
|
||||||
var content = BuildSendForm(phone, message, sender);
|
await RunSdkAsync(apiKey, api =>
|
||||||
|
{
|
||||||
var response = await _httpClient.PostAsync(url, content, cancellationToken);
|
api.Send(sender, receptor, message);
|
||||||
await EnsureKavenegarSuccessAsync(response, "Send", cancellationToken);
|
}, "Send");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<BulkSendResult> SendBulkAsync(
|
public async Task<BulkSendResult> SendBulkAsync(
|
||||||
@@ -103,17 +94,18 @@ public class KavenegarSmsService : ISmsService
|
|||||||
return new BulkSendResult(0, phones.Count);
|
return new BulkSendResult(0, phones.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
var url = $"{BaseUrl}/{apiKey}/sms/send.json";
|
|
||||||
int sent = 0, failed = 0;
|
int sent = 0, failed = 0;
|
||||||
|
|
||||||
foreach (var batch in phones.Chunk(MaxBatchSize))
|
foreach (var batch in phones.Chunk(MaxBatchSize))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Kavenegar /sms/send.json accepts comma-separated receptors
|
var receptors = batch.Select(NormalizePhone).ToList();
|
||||||
var content = BuildSendForm(string.Join(",", batch), message, sender);
|
await RunSdkAsync(apiKey, api =>
|
||||||
var response = await _httpClient.PostAsync(url, content, cancellationToken);
|
{
|
||||||
await EnsureKavenegarSuccessAsync(response, "BulkSend", cancellationToken);
|
api.Send(sender, receptors, message);
|
||||||
|
}, "BulkSend");
|
||||||
|
|
||||||
sent += batch.Length;
|
sent += batch.Length;
|
||||||
_logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length);
|
_logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length);
|
||||||
}
|
}
|
||||||
@@ -134,20 +126,12 @@ public class KavenegarSmsService : ISmsService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"{BaseUrl}/{apiKey}/account/info.json";
|
return await Task.Run(() =>
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Kavenegar account info returned HTTP {Status}", response.StatusCode);
|
var api = new KavenegarApi(apiKey);
|
||||||
return null;
|
var info = api.AccountInfo();
|
||||||
}
|
return new KavenegarAccountInfo(info.RemainCredit, info.Type ?? "master");
|
||||||
|
}, cancellationToken);
|
||||||
var body = await response.Content.ReadFromJsonAsync<KavenegarAccountInfoResponse>(cancellationToken: cancellationToken);
|
|
||||||
if (body?.Return?.Status is not 200 || body.Entries is null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new KavenegarAccountInfo(body.Entries.RemainCredit, body.Entries.Type ?? "master");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -156,42 +140,42 @@ public class KavenegarSmsService : ISmsService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SDK runner ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a synchronous Kavenegar SDK call on the thread pool.
|
||||||
|
/// Translates SDK exceptions to logged InvalidOperationException.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RunSdkAsync(string apiKey, Action<KavenegarApi> action, string operation)
|
||||||
|
{
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var api = new KavenegarApi(apiKey);
|
||||||
|
action(api);
|
||||||
|
}
|
||||||
|
catch (ApiException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Kavenegar {Op} API error {Code}: {Message}",
|
||||||
|
operation, ex.Code, ex.Message);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Kavenegar {operation} failed (code {ex.Code}): {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
catch (HttpException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Kavenegar {Op} HTTP error {Code}: {Message}",
|
||||||
|
operation, ex.Code, ex.Message);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Kavenegar {operation} HTTP error (code {ex.Code}): {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static FormUrlEncodedContent BuildSendForm(string receptor, string message, string sender)
|
|
||||||
{
|
|
||||||
var dict = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["receptor"] = receptor,
|
|
||||||
["message"] = message,
|
|
||||||
};
|
|
||||||
if (!string.IsNullOrWhiteSpace(sender))
|
|
||||||
dict["sender"] = sender;
|
|
||||||
return new FormUrlEncodedContent(dict);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task EnsureKavenegarSuccessAsync(
|
|
||||||
HttpResponseMessage response,
|
|
||||||
string operation,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var errorCode = (int)response.StatusCode;
|
|
||||||
var detail = KavenegarHttpError(errorCode);
|
|
||||||
_logger.LogWarning("Kavenegar {Op} HTTP {Code}: {Detail}", operation, errorCode, detail);
|
|
||||||
throw new InvalidOperationException($"Kavenegar {operation} failed (HTTP {errorCode}): {detail}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var body = await response.Content.ReadFromJsonAsync<KavenegarReturnEnvelope>(cancellationToken: cancellationToken);
|
|
||||||
if (body?.Return?.Status is not 200)
|
|
||||||
{
|
|
||||||
var status = body?.Return?.Status ?? -1;
|
|
||||||
_logger.LogWarning("Kavenegar {Op} returned status {Status}: {Message}", operation, status, body?.Return?.Message);
|
|
||||||
throw new InvalidOperationException($"Kavenegar {operation} failed (status {status}): {body?.Return?.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip leading 0 from Iranian mobile numbers (09xxxxxxxxx → 9xxxxxxxxx)
|
// Strip leading 0 from Iranian mobile numbers (09xxxxxxxxx → 9xxxxxxxxx)
|
||||||
private static string NormalizePhone(string phone)
|
private static string NormalizePhone(string phone)
|
||||||
{
|
{
|
||||||
@@ -200,35 +184,6 @@ public class KavenegarSmsService : ISmsService
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string KavenegarHttpError(int code) => code switch
|
|
||||||
{
|
|
||||||
400 => "Missing or invalid parameters",
|
|
||||||
401 => "Account is inactive",
|
|
||||||
403 => "Invalid API key",
|
|
||||||
404 => "Method not found",
|
|
||||||
405 => "Wrong HTTP method",
|
|
||||||
406 => "Recipient is on the blacklist or number is deactivated",
|
|
||||||
411 => "Invalid recipient number",
|
|
||||||
412 => "Invalid sender number",
|
|
||||||
413 => "Message empty or too long",
|
|
||||||
414 => "Too many recipients",
|
|
||||||
415 => "Server error on Kavenegar side",
|
|
||||||
416 => "Recipient is invalid, blacklisted, or deactivated",
|
|
||||||
417 => "Invalid scheduled date",
|
|
||||||
418 => "Insufficient credit",
|
|
||||||
419 => "OTP token already used or expired",
|
|
||||||
420 => "IP not allowed",
|
|
||||||
421 => "Message could not be sent",
|
|
||||||
422 => "Invalid characters in message",
|
|
||||||
423 => "Kavenegar server unreachable",
|
|
||||||
424 => "OTP template not found — check template name in Kavenegar panel",
|
|
||||||
426 => "IP is not whitelisted",
|
|
||||||
428 => "Voice call requires numeric token",
|
|
||||||
431 => "SMS sending is disabled on this account",
|
|
||||||
432 => "Code parameter missing in OTP template",
|
|
||||||
_ => $"Undocumented Kavenegar error {code}"
|
|
||||||
};
|
|
||||||
|
|
||||||
private async Task<(string? ApiKey, string Sender, string OtpTemplate)> GetConfigAsync(CancellationToken ct)
|
private async Task<(string? ApiKey, string Sender, string OtpTemplate)> GetConfigAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var enabled = await _platform.GetAsync(DbKeyEnabled, ct);
|
var enabled = await _platform.GetAsync(DbKeyEnabled, ct);
|
||||||
@@ -250,42 +205,4 @@ public class KavenegarSmsService : ISmsService
|
|||||||
|
|
||||||
return (apiKey, sender, template);
|
return (apiKey, sender, template);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Response models ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private sealed class KavenegarReturnEnvelope
|
|
||||||
{
|
|
||||||
[JsonPropertyName("return")]
|
|
||||||
public KavenegarReturn? Return { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class KavenegarReturn
|
|
||||||
{
|
|
||||||
[JsonPropertyName("status")]
|
|
||||||
public int Status { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("message")]
|
|
||||||
public string? Message { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class KavenegarAccountInfoResponse
|
|
||||||
{
|
|
||||||
[JsonPropertyName("return")]
|
|
||||||
public KavenegarReturn? Return { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("entries")]
|
|
||||||
public KavenegarAccountEntries? Entries { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class KavenegarAccountEntries
|
|
||||||
{
|
|
||||||
[JsonPropertyName("remaincredit")]
|
|
||||||
public long RemainCredit { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("expiredate")]
|
|
||||||
public long ExpireDate { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("type")]
|
|
||||||
public string? Type { get; set; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
|
<PackageReference Include="Kavenegar" />
|
||||||
<PackageReference Include="System.Security.Cryptography.Xml" />
|
<PackageReference Include="System.Security.Cryptography.Xml" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,33 @@
|
|||||||
"delivery": "عامل التوصيل",
|
"delivery": "عامل التوصيل",
|
||||||
"unknown": "مستخدم"
|
"unknown": "مستخدم"
|
||||||
},
|
},
|
||||||
|
"branchSwitcher": {
|
||||||
|
"title": "الفرع النشط",
|
||||||
|
"allBranches": "كل الفروع",
|
||||||
|
"selectBranch": "اختر الفرع"
|
||||||
|
},
|
||||||
|
"branchAccess": {
|
||||||
|
"title": "صلاحيات الفروع",
|
||||||
|
"staff": "الموظفون",
|
||||||
|
"noStaff": "لا يوجد موظفون بعد",
|
||||||
|
"selectStaff": "اختر موظفًا لإدارة الصلاحيات",
|
||||||
|
"ownerNote": "المالك لديه صلاحية الوصول لكل الفروع ولا يحتاج إلى أدوار خاصة بكل فرع.",
|
||||||
|
"noAssignments": "لم يتم تعيين أي دور للفروع بعد",
|
||||||
|
"loading": "جارٍ التحميل...",
|
||||||
|
"branch": "الفرع",
|
||||||
|
"role": "الدور",
|
||||||
|
"selectBranch": "اختر الفرع",
|
||||||
|
"add": "إضافة",
|
||||||
|
"remove": "حذف"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"deniedTitle": "لا تملك صلاحية الوصول إلى هذه الصفحة",
|
||||||
|
"deniedBody": "دورك لا يملك صلاحية عرض هذه الصفحة. تواصل مع المدير أو المالك إذا كنت بحاجة إلى الوصول."
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"aria": "القائمة الرئيسية",
|
"aria": "القائمة الرئيسية",
|
||||||
|
"collapseSidebar": "طي الشريط الجانبي",
|
||||||
|
"expandSidebar": "توسيع الشريط الجانبي",
|
||||||
"groups": {
|
"groups": {
|
||||||
"operations": "العمليات اليومية",
|
"operations": "العمليات اليومية",
|
||||||
"menuSales": "القائمة والمبيعات",
|
"menuSales": "القائمة والمبيعات",
|
||||||
@@ -161,6 +186,8 @@
|
|||||||
"cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.",
|
"cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.",
|
||||||
"cancelOrderSuccess": "تم إلغاء الطلب",
|
"cancelOrderSuccess": "تم إلغاء الطلب",
|
||||||
"cancelOrderError": "تعذّر إلغاء الطلب",
|
"cancelOrderError": "تعذّر إلغاء الطلب",
|
||||||
|
"cancelReasonPlaceholder": "سبب الإلغاء (اختياري)",
|
||||||
|
"cancelOrderHasPayments": "استرجع المدفوعات المسجّلة أولاً ثم ألغِ الطلب",
|
||||||
"itemsCount": "صنف",
|
"itemsCount": "صنف",
|
||||||
"applyCoupon": "تطبيق القسيمة",
|
"applyCoupon": "تطبيق القسيمة",
|
||||||
"couponPlaceholder": "رمز القسيمة",
|
"couponPlaceholder": "رمز القسيمة",
|
||||||
@@ -358,7 +385,8 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"attendance": "الحضور",
|
"attendance": "الحضور",
|
||||||
"leave": "الإجازة",
|
"leave": "الإجازة",
|
||||||
"payroll": "الرواتب"
|
"payroll": "الرواتب",
|
||||||
|
"access": "صلاحيات الفروع"
|
||||||
},
|
},
|
||||||
"myAttendance": "حضوري",
|
"myAttendance": "حضوري",
|
||||||
"clockIn": "تسجيل دخول",
|
"clockIn": "تسجيل دخول",
|
||||||
@@ -945,7 +973,28 @@
|
|||||||
"featureDiscover": "ملف الاكتشاف (ذكاء اصطناعي)",
|
"featureDiscover": "ملف الاكتشاف (ذكاء اصطناعي)",
|
||||||
"featureOn": "مفعّل",
|
"featureOn": "مفعّل",
|
||||||
"featureOff": "غير متاح — ترقية",
|
"featureOff": "غير متاح — ترقية",
|
||||||
"featureMenu3dUpgrade": "القائمة 3D متاحة في برو وما فوق."
|
"featureMenu3dUpgrade": "القائمة 3D متاحة في برو وما فوق.",
|
||||||
|
"checkout": {
|
||||||
|
"title": "الفاتورة والدفع",
|
||||||
|
"subtitle": "راجع طلبك وادفع",
|
||||||
|
"backToPlans": "العودة إلى الخطط",
|
||||||
|
"invalidPlan": "الخطة المحددة غير متاحة للشراء عبر الإنترنت.",
|
||||||
|
"invoiceLabel": "فاتورة مبدئية",
|
||||||
|
"invoiceNo": "رقم الفاتورة",
|
||||||
|
"issuedAt": "تاريخ الإصدار",
|
||||||
|
"billingPeriod": "مدة الاشتراك",
|
||||||
|
"monthsCount": "{count} شهر",
|
||||||
|
"description": "الوصف",
|
||||||
|
"qty": "الكمية",
|
||||||
|
"unitPrice": "سعر الوحدة",
|
||||||
|
"amount": "المبلغ",
|
||||||
|
"planLine": "اشتراك خطة {plan}",
|
||||||
|
"subtotal": "المجموع الفرعي",
|
||||||
|
"total": "المبلغ المستحق",
|
||||||
|
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
|
||||||
|
"payTotal": "ادفع {total}",
|
||||||
|
"redirecting": "جارٍ التحويل إلى البوابة..."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "الإعدادات",
|
"title": "الإعدادات",
|
||||||
|
|||||||
@@ -67,8 +67,33 @@
|
|||||||
"delivery": "Delivery",
|
"delivery": "Delivery",
|
||||||
"unknown": "User"
|
"unknown": "User"
|
||||||
},
|
},
|
||||||
|
"branchSwitcher": {
|
||||||
|
"title": "Active branch",
|
||||||
|
"allBranches": "All branches",
|
||||||
|
"selectBranch": "Select branch"
|
||||||
|
},
|
||||||
|
"branchAccess": {
|
||||||
|
"title": "Branch access",
|
||||||
|
"staff": "Staff",
|
||||||
|
"noStaff": "No staff yet",
|
||||||
|
"selectStaff": "Select a staff member to manage access",
|
||||||
|
"ownerNote": "The owner has access to all branches and does not need per-branch roles.",
|
||||||
|
"noAssignments": "No branch roles assigned yet",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"branch": "Branch",
|
||||||
|
"role": "Role",
|
||||||
|
"selectBranch": "Select branch",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"deniedTitle": "No access to this page",
|
||||||
|
"deniedBody": "Your role doesn't have permission to view this page. Contact a manager or owner if you need access."
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"aria": "Main navigation",
|
"aria": "Main navigation",
|
||||||
|
"collapseSidebar": "Collapse sidebar",
|
||||||
|
"expandSidebar": "Expand sidebar",
|
||||||
"groups": {
|
"groups": {
|
||||||
"operations": "Daily operations",
|
"operations": "Daily operations",
|
||||||
"menuSales": "Menu & sales",
|
"menuSales": "Menu & sales",
|
||||||
@@ -180,6 +205,8 @@
|
|||||||
"cancelOrderConfirm": "Customer left without paying? The order will be cancelled and the table freed.",
|
"cancelOrderConfirm": "Customer left without paying? The order will be cancelled and the table freed.",
|
||||||
"cancelOrderSuccess": "Order cancelled",
|
"cancelOrderSuccess": "Order cancelled",
|
||||||
"cancelOrderError": "Could not cancel order",
|
"cancelOrderError": "Could not cancel order",
|
||||||
|
"cancelReasonPlaceholder": "Cancellation reason (optional)",
|
||||||
|
"cancelOrderHasPayments": "Refund the recorded payments first, then cancel the order",
|
||||||
"itemsCount": "items",
|
"itemsCount": "items",
|
||||||
"applyCoupon": "Apply coupon",
|
"applyCoupon": "Apply coupon",
|
||||||
"couponPlaceholder": "Coupon code",
|
"couponPlaceholder": "Coupon code",
|
||||||
@@ -377,7 +404,8 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"attendance": "Attendance",
|
"attendance": "Attendance",
|
||||||
"leave": "Leave",
|
"leave": "Leave",
|
||||||
"payroll": "Payroll"
|
"payroll": "Payroll",
|
||||||
|
"access": "Branch access"
|
||||||
},
|
},
|
||||||
"myAttendance": "My attendance",
|
"myAttendance": "My attendance",
|
||||||
"clockIn": "Clock in",
|
"clockIn": "Clock in",
|
||||||
@@ -1017,7 +1045,28 @@
|
|||||||
"featureOff": "Not included — upgrade",
|
"featureOff": "Not included — upgrade",
|
||||||
"featureMenu3dUpgrade": "3D menu is available on Pro and higher plans.",
|
"featureMenu3dUpgrade": "3D menu is available on Pro and higher plans.",
|
||||||
"featureMenuAi3d": "AI 3D generation",
|
"featureMenuAi3d": "AI 3D generation",
|
||||||
"featureMenuAi3dUpgrade": "AI 3D generation is on Business and Enterprise (100 per month)."
|
"featureMenuAi3dUpgrade": "AI 3D generation is on Business and Enterprise (100 per month).",
|
||||||
|
"checkout": {
|
||||||
|
"title": "Invoice & Payment",
|
||||||
|
"subtitle": "Review your order and pay",
|
||||||
|
"backToPlans": "Back to plans",
|
||||||
|
"invalidPlan": "The selected plan is not available for online purchase.",
|
||||||
|
"invoiceLabel": "Proforma invoice",
|
||||||
|
"invoiceNo": "Invoice no.",
|
||||||
|
"issuedAt": "Issued",
|
||||||
|
"billingPeriod": "Billing period",
|
||||||
|
"monthsCount": "{count} mo",
|
||||||
|
"description": "Description",
|
||||||
|
"qty": "Qty",
|
||||||
|
"unitPrice": "Unit price",
|
||||||
|
"amount": "Amount",
|
||||||
|
"planLine": "{plan} plan subscription",
|
||||||
|
"subtotal": "Subtotal",
|
||||||
|
"total": "Amount due",
|
||||||
|
"secureNote": "Payment is processed through a secure bank gateway.",
|
||||||
|
"payTotal": "Pay {total}",
|
||||||
|
"redirecting": "Redirecting to gateway..."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
|||||||
@@ -67,8 +67,33 @@
|
|||||||
"delivery": "پیک",
|
"delivery": "پیک",
|
||||||
"unknown": "کاربر"
|
"unknown": "کاربر"
|
||||||
},
|
},
|
||||||
|
"branchSwitcher": {
|
||||||
|
"title": "شعبه فعال",
|
||||||
|
"allBranches": "همه شعب",
|
||||||
|
"selectBranch": "انتخاب شعبه"
|
||||||
|
},
|
||||||
|
"branchAccess": {
|
||||||
|
"title": "دسترسی شعب",
|
||||||
|
"staff": "کارکنان",
|
||||||
|
"noStaff": "کارمندی ثبت نشده است",
|
||||||
|
"selectStaff": "یک کارمند را برای مدیریت دسترسی انتخاب کنید",
|
||||||
|
"ownerNote": "مالک به همه شعب دسترسی دارد و نیازی به تعیین نقش شعبهای ندارد.",
|
||||||
|
"noAssignments": "هنوز نقشی برای شعبهای تعیین نشده است",
|
||||||
|
"loading": "در حال بارگذاری...",
|
||||||
|
"branch": "شعبه",
|
||||||
|
"role": "نقش",
|
||||||
|
"selectBranch": "انتخاب شعبه",
|
||||||
|
"add": "افزودن",
|
||||||
|
"remove": "حذف"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"deniedTitle": "دسترسی به این صفحه ندارید",
|
||||||
|
"deniedBody": "نقش شما اجازه مشاهده این صفحه را ندارد. در صورت نیاز با مدیر یا مالک هماهنگ کنید."
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"aria": "منوی اصلی",
|
"aria": "منوی اصلی",
|
||||||
|
"collapseSidebar": "جمع کردن نوار کناری",
|
||||||
|
"expandSidebar": "باز کردن نوار کناری",
|
||||||
"groups": {
|
"groups": {
|
||||||
"operations": "عملیات روزانه",
|
"operations": "عملیات روزانه",
|
||||||
"menuSales": "منو و فروش",
|
"menuSales": "منو و فروش",
|
||||||
@@ -180,6 +205,8 @@
|
|||||||
"cancelOrderConfirm": "مشتری بدون پرداخت رفته است؟ سفارش لغو میشود و میز آزاد میشود.",
|
"cancelOrderConfirm": "مشتری بدون پرداخت رفته است؟ سفارش لغو میشود و میز آزاد میشود.",
|
||||||
"cancelOrderSuccess": "سفارش لغو شد",
|
"cancelOrderSuccess": "سفارش لغو شد",
|
||||||
"cancelOrderError": "لغو سفارش ناموفق بود",
|
"cancelOrderError": "لغو سفارش ناموفق بود",
|
||||||
|
"cancelReasonPlaceholder": "دلیل لغو (اختیاری)",
|
||||||
|
"cancelOrderHasPayments": "ابتدا پرداختهای ثبتشده را بازگردانید، سپس سفارش را لغو کنید",
|
||||||
"itemsCount": "قلم",
|
"itemsCount": "قلم",
|
||||||
"applyCoupon": "اعمال کوپن",
|
"applyCoupon": "اعمال کوپن",
|
||||||
"couponPlaceholder": "کد کوپن",
|
"couponPlaceholder": "کد کوپن",
|
||||||
@@ -377,7 +404,8 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"attendance": "حضور و غیاب",
|
"attendance": "حضور و غیاب",
|
||||||
"leave": "مرخصی",
|
"leave": "مرخصی",
|
||||||
"payroll": "حقوق"
|
"payroll": "حقوق",
|
||||||
|
"access": "دسترسی شعب"
|
||||||
},
|
},
|
||||||
"myAttendance": "حضور من",
|
"myAttendance": "حضور من",
|
||||||
"clockIn": "ورود",
|
"clockIn": "ورود",
|
||||||
@@ -1018,7 +1046,28 @@
|
|||||||
"featureOff": "غیرفعال — ارتقا دهید",
|
"featureOff": "غیرفعال — ارتقا دهید",
|
||||||
"featureMenu3dUpgrade": "منوی ۳D در پلن حرفهای و بالاتر فعال است.",
|
"featureMenu3dUpgrade": "منوی ۳D در پلن حرفهای و بالاتر فعال است.",
|
||||||
"featureMenuAi3d": "تولید ۳D با AI",
|
"featureMenuAi3d": "تولید ۳D با AI",
|
||||||
"featureMenuAi3dUpgrade": "تولید ۳D با هوش مصنوعی در پلن کسبوکار و سازمانی (۱۰۰ بار در ماه) فعال است."
|
"featureMenuAi3dUpgrade": "تولید ۳D با هوش مصنوعی در پلن کسبوکار و سازمانی (۱۰۰ بار در ماه) فعال است.",
|
||||||
|
"checkout": {
|
||||||
|
"title": "پیشفاکتور و پرداخت",
|
||||||
|
"subtitle": "جزئیات سفارش را بررسی و پرداخت کنید",
|
||||||
|
"backToPlans": "بازگشت به پلنها",
|
||||||
|
"invalidPlan": "پلن انتخابشده برای خرید آنلاین معتبر نیست.",
|
||||||
|
"invoiceLabel": "پیشفاکتور",
|
||||||
|
"invoiceNo": "شماره فاکتور",
|
||||||
|
"issuedAt": "تاریخ صدور",
|
||||||
|
"billingPeriod": "مدت اشتراک",
|
||||||
|
"monthsCount": "{count} ماه",
|
||||||
|
"description": "شرح",
|
||||||
|
"qty": "تعداد",
|
||||||
|
"unitPrice": "قیمت واحد",
|
||||||
|
"amount": "مبلغ",
|
||||||
|
"planLine": "اشتراک پلن {plan}",
|
||||||
|
"subtotal": "جمع جزء",
|
||||||
|
"total": "مبلغ قابل پرداخت",
|
||||||
|
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام میشود.",
|
||||||
|
"payTotal": "پرداخت {total}",
|
||||||
|
"redirecting": "در حال انتقال به درگاه..."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "تنظیمات",
|
"title": "تنظیمات",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useLocale } from "next-intl";
|
|||||||
import { useRouter } from "@/i18n/routing";
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
import { Topbar } from "@/components/layout/topbar";
|
import { Topbar } from "@/components/layout/topbar";
|
||||||
|
import { RouteGuard } from "@/components/auth/route-guard";
|
||||||
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
|
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { useOfflineSync } from "@/lib/offline/use-offline-sync";
|
import { useOfflineSync } from "@/lib/offline/use-offline-sync";
|
||||||
@@ -31,7 +32,7 @@ export default function DashboardLayout({
|
|||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
<Topbar />
|
<Topbar />
|
||||||
<main className="min-h-0 flex-1 overflow-auto p-6 bg-background">
|
<main className="min-h-0 flex-1 overflow-auto p-6 bg-background">
|
||||||
{children}
|
<RouteGuard>{children}</RouteGuard>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { CheckoutScreen } from "@/components/subscription/checkout-screen";
|
||||||
|
|
||||||
|
export default function SubscriptionCheckoutPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CheckoutScreen />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
|||||||
import { useLocale } from "next-intl";
|
import { useLocale } from "next-intl";
|
||||||
import { useRouter } from "@/i18n/routing";
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
|
import { RouteGuard } from "@/components/auth/route-guard";
|
||||||
|
|
||||||
/** Full-viewport routes (queue TV display) — auth only, no dashboard chrome. */
|
/** Full-viewport routes (queue TV display) — auth only, no dashboard chrome. */
|
||||||
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
|
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -19,7 +20,7 @@ export default function FullscreenLayout({ children }: { children: React.ReactNo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh" dir={locale === "en" ? "ltr" : "rtl"}>
|
<div className="min-h-svh" dir={locale === "en" ? "ltr" : "rtl"}>
|
||||||
{children}
|
<RouteGuard>{children}</RouteGuard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
|
import { Topbar } from "@/components/layout/topbar";
|
||||||
|
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POS route layout — wraps the terminal in the standard dashboard chrome
|
||||||
|
* (collapsible sidebar + topbar) but keeps the main content area
|
||||||
|
* overflow-hidden so PosScreen can manage its own internal scrolling.
|
||||||
|
*/
|
||||||
|
export default function PosLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const locale = useLocale();
|
||||||
|
const isRtl = locale !== "en";
|
||||||
|
|
||||||
|
const mainColumn = (
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
|
<Topbar />
|
||||||
|
<main className="min-h-0 flex-1 overflow-hidden bg-background p-3 md:p-4">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CafeThemeProvider>
|
||||||
|
<div
|
||||||
|
className="flex h-screen min-h-0 overflow-hidden bg-background"
|
||||||
|
dir={isRtl ? "rtl" : "ltr"}
|
||||||
|
>
|
||||||
|
{isRtl ? (
|
||||||
|
<>
|
||||||
|
<Sidebar side="right" />
|
||||||
|
{mainColumn}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sidebar side="left" />
|
||||||
|
{mainColumn}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CafeThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
|
|
||||||
import { PosScreen } from "@/components/pos/pos-screen";
|
import { PosScreen } from "@/components/pos/pos-screen";
|
||||||
|
|
||||||
/** Full-viewport POS terminal — no sidebar, no topbar. */
|
/** POS terminal — chrome (sidebar + topbar) is provided by layout.tsx */
|
||||||
export default function PosPage() {
|
export default function PosPage() {
|
||||||
return (
|
return (
|
||||||
<CafeThemeProvider>
|
|
||||||
<div className="flex h-screen min-h-0 flex-col overflow-hidden bg-background p-4 md:p-6">
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<PosScreen />
|
<PosScreen />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
|
||||||
</CafeThemeProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Link, usePathname } from "@/i18n/routing";
|
import { Link, usePathname } from "@/i18n/routing";
|
||||||
import { canSeeNavGroup, canSeeNavItem } from "@/lib/auth-permissions";
|
import { canSeeNavGroup, canSeeNavItem } from "@/lib/auth-permissions";
|
||||||
|
import { permissionsOf } from "@/lib/permissions";
|
||||||
import {
|
import {
|
||||||
NAV_GROUPS,
|
NAV_GROUPS,
|
||||||
NAV_GROUPS_STORAGE_KEY,
|
NAV_GROUPS_STORAGE_KEY,
|
||||||
@@ -18,6 +19,17 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
type OpenGroupsState = Partial<Record<NavGroupId, boolean>>;
|
type OpenGroupsState = Partial<Record<NavGroupId, boolean>>;
|
||||||
|
|
||||||
|
const SIDEBAR_COLLAPSED_STORAGE_KEY = "meezi.sidebar.collapsed";
|
||||||
|
|
||||||
|
function readStoredCollapsed(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readStoredOpenGroups(): OpenGroupsState {
|
function readStoredOpenGroups(): OpenGroupsState {
|
||||||
if (typeof window === "undefined") return {};
|
if (typeof window === "undefined") return {};
|
||||||
try {
|
try {
|
||||||
@@ -50,17 +62,22 @@ function NavLink({
|
|||||||
item,
|
item,
|
||||||
label,
|
label,
|
||||||
active,
|
active,
|
||||||
|
collapsed,
|
||||||
}: {
|
}: {
|
||||||
item: NavItemDef;
|
item: NavItemDef;
|
||||||
label: string;
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
title={collapsed ? label : undefined}
|
||||||
|
aria-label={collapsed ? label : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex items-center rounded-lg px-3 py-2 text-sm transition-colors cursor-pointer",
|
"group flex items-center rounded-lg py-2 text-sm transition-colors cursor-pointer",
|
||||||
|
collapsed ? "justify-center px-0" : "px-3",
|
||||||
active
|
active
|
||||||
? "bg-accent text-accent-foreground font-medium"
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||||
@@ -68,11 +85,12 @@ function NavLink({
|
|||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 shrink-0 me-2.5",
|
"h-4 w-4 shrink-0",
|
||||||
|
collapsed ? "" : "me-2.5",
|
||||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 truncate">{label}</span>
|
{!collapsed && <span className="min-w-0 truncate">{label}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -85,7 +103,9 @@ function NavGroupSection({
|
|||||||
pathname,
|
pathname,
|
||||||
role,
|
role,
|
||||||
branchId,
|
branchId,
|
||||||
|
permissions,
|
||||||
tItem,
|
tItem,
|
||||||
|
collapsed,
|
||||||
}: {
|
}: {
|
||||||
group: NavGroupDef;
|
group: NavGroupDef;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -94,13 +114,37 @@ function NavGroupSection({
|
|||||||
pathname: string;
|
pathname: string;
|
||||||
role: string | undefined;
|
role: string | undefined;
|
||||||
branchId: string | null | undefined;
|
branchId: string | null | undefined;
|
||||||
|
permissions: Set<string> | null;
|
||||||
tItem: (key: string) => string;
|
tItem: (key: string) => string;
|
||||||
|
collapsed: boolean;
|
||||||
}) {
|
}) {
|
||||||
const visibleItems = group.items.filter((item) =>
|
const visibleItems = group.items.filter((item) =>
|
||||||
canSeeNavItem(item.key, role, branchId)
|
canSeeNavItem(item.key, role, branchId, permissions)
|
||||||
);
|
);
|
||||||
if (visibleItems.length === 0) return null;
|
if (visibleItems.length === 0) return null;
|
||||||
|
|
||||||
|
// Collapsed: drop the group header, show items as a flat icon-only list
|
||||||
|
// with a subtle divider between groups.
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div className="mb-1 space-y-0.5 border-t border-border/60 pt-1 first:border-t-0 first:pt-0">
|
||||||
|
{visibleItems.map((item) => {
|
||||||
|
const active =
|
||||||
|
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.key}
|
||||||
|
item={item}
|
||||||
|
label={tItem(item.key)}
|
||||||
|
active={active}
|
||||||
|
collapsed
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
<button
|
<button
|
||||||
@@ -137,6 +181,7 @@ function NavGroupSection({
|
|||||||
item={item}
|
item={item}
|
||||||
label={tItem(item.key)}
|
label={tItem(item.key)}
|
||||||
active={active}
|
active={active}
|
||||||
|
collapsed={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -156,16 +201,41 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
|
|||||||
const hasHydrated = useAuthStore((s) => s._hasHydrated);
|
const hasHydrated = useAuthStore((s) => s._hasHydrated);
|
||||||
const role = user?.role;
|
const role = user?.role;
|
||||||
const branchId = user?.branchId ?? null;
|
const branchId = user?.branchId ?? null;
|
||||||
|
const permissions = useMemo(() => permissionsOf(user), [user]);
|
||||||
|
|
||||||
const [openGroups, setOpenGroups] = useState<OpenGroupsState>(buildDefaultOpenGroups);
|
const [openGroups, setOpenGroups] = useState<OpenGroupsState>(buildDefaultOpenGroups);
|
||||||
|
const [collapsed, setCollapsed] = useState<boolean>(readStoredCollapsed);
|
||||||
|
|
||||||
|
const toggleCollapsed = useCallback(() => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, next ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
/* ignore quota */
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Chevron points "inward" (toward the panel) to collapse, "outward" to expand.
|
||||||
|
// For a left-docked sidebar inward = left; for a right-docked one inward = right.
|
||||||
|
const CollapseIcon =
|
||||||
|
side === "left"
|
||||||
|
? collapsed
|
||||||
|
? ChevronRight
|
||||||
|
: ChevronLeft
|
||||||
|
: collapsed
|
||||||
|
? ChevronLeft
|
||||||
|
: ChevronRight;
|
||||||
|
|
||||||
const visibleGroups = useMemo(
|
const visibleGroups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
NAV_GROUPS.filter((g) => {
|
NAV_GROUPS.filter((g) => {
|
||||||
if (!canSeeNavGroup(g.id, role, branchId)) return false;
|
if (!canSeeNavGroup(g.id, role, branchId)) return false;
|
||||||
return g.items.some((item) => canSeeNavItem(item.key, role, branchId));
|
return g.items.some((item) => canSeeNavItem(item.key, role, branchId, permissions));
|
||||||
}),
|
}),
|
||||||
[role, branchId]
|
[role, branchId, permissions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => {
|
const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => {
|
||||||
@@ -190,21 +260,30 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-56 shrink-0 flex-col bg-background",
|
"flex shrink-0 flex-col bg-background overflow-hidden",
|
||||||
|
"transition-[width] duration-200 ease-in-out",
|
||||||
|
collapsed ? "w-14" : "w-56",
|
||||||
"border-border",
|
"border-border",
|
||||||
side === "right" ? "border-s" : "border-e"
|
side === "right" ? "border-s" : "border-e"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex h-14 items-center gap-2.5 px-4 border-b border-border">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-14 shrink-0 items-center border-b border-border",
|
||||||
|
collapsed ? "justify-center px-0" : "gap-2.5 px-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-primary" aria-hidden>
|
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-primary" aria-hidden>
|
||||||
<path d="M3 6h18v2H3V6zm2 4h14v2H5v-2zm-2 4h18v2H3v-2zm4 4h10v2H7v-2z" />
|
<path d="M3 6h18v2H3V6zm2 4h14v2H5v-2zm-2 4h18v2H3v-2zm4 4h10v2H7v-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-bold tracking-tight text-foreground">
|
{!collapsed && (
|
||||||
|
<span className="min-w-0 truncate text-sm font-bold tracking-tight text-foreground">
|
||||||
{tBrand("name")}
|
{tBrand("name")}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
@@ -220,6 +299,15 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
|
|||||||
/* Skeleton — shown for ~50ms until Zustand rehydrates from localStorage.
|
/* Skeleton — shown for ~50ms until Zustand rehydrates from localStorage.
|
||||||
Prevents the flash where all groups are briefly visible before
|
Prevents the flash where all groups are briefly visible before
|
||||||
permission-based filtering kicks in for branch-scoped accounts. */
|
permission-based filtering kicks in for branch-scoped accounts. */
|
||||||
|
collapsed ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex justify-center">
|
||||||
|
<div className="h-8 w-8 animate-pulse rounded-lg bg-muted" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-3 px-1 pt-1">
|
<div className="space-y-3 px-1 pt-1">
|
||||||
{[40, 32, 40, 32, 40].map((w, i) => (
|
{[40, 32, 40, 32, 40].map((w, i) => (
|
||||||
<div key={i} className="space-y-1.5">
|
<div key={i} className="space-y-1.5">
|
||||||
@@ -227,13 +315,14 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
|
|||||||
{Array.from({ length: i % 2 === 0 ? 3 : 2 }).map((_, j) => (
|
{Array.from({ length: i % 2 === 0 ? 3 : 2 }).map((_, j) => (
|
||||||
<div
|
<div
|
||||||
key={j}
|
key={j}
|
||||||
className={`h-8 animate-pulse rounded-lg bg-muted`}
|
className="h-8 animate-pulse rounded-lg bg-muted"
|
||||||
style={{ width: `${w + j * 4}%` }}
|
style={{ width: `${w + j * 4}%` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
visibleGroups.map((group) => {
|
visibleGroups.map((group) => {
|
||||||
const isOpen = openGroups[group.id] ?? group.defaultOpen;
|
const isOpen = openGroups[group.id] ?? group.defaultOpen;
|
||||||
@@ -247,31 +336,63 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
|
|||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
role={role}
|
role={role}
|
||||||
branchId={branchId}
|
branchId={branchId}
|
||||||
|
permissions={permissions}
|
||||||
tItem={(key) => t(key)}
|
tItem={(key) => t(key)}
|
||||||
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer — user role badge */}
|
{/* Footer — user info + collapse toggle */}
|
||||||
|
<div className="shrink-0 border-t border-border">
|
||||||
|
{/* User badge */}
|
||||||
{user && (
|
{user && (
|
||||||
<div className="border-t border-border px-4 py-3">
|
<div
|
||||||
<div className="flex items-center gap-2">
|
className={cn(
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
"flex items-center py-3",
|
||||||
|
collapsed ? "justify-center px-0" : "gap-2 px-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10"
|
||||||
|
title={collapsed ? (user.actor ?? user.userId) : undefined}
|
||||||
|
>
|
||||||
<span className="text-[11px] font-semibold text-primary">
|
<span className="text-[11px] font-semibold text-primary">
|
||||||
{(user.actor ?? user.role).charAt(0).toUpperCase()}
|
{(user.actor ?? user.role).charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-xs font-medium text-foreground">
|
<p className="truncate text-xs font-medium text-foreground">
|
||||||
{user.actor ?? user.userId}
|
{user.actor ?? user.userId}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[10px] text-muted-foreground">{user.role}</p>
|
<p className="truncate text-[10px] text-muted-foreground">
|
||||||
</div>
|
{user.role}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapse toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
title={collapsed ? t("expandSidebar") : t("collapseSidebar")}
|
||||||
|
aria-label={collapsed ? t("expandSidebar") : t("collapseSidebar")}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 border-t border-border/50 py-2.5 text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground cursor-pointer",
|
||||||
|
collapsed ? "justify-center px-0" : "px-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CollapseIcon className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="text-xs">{t("collapseSidebar")}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
|
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
|
||||||
|
import { BranchSwitcher } from "@/components/layout/branch-switcher";
|
||||||
import { NotificationCenter } from "@/components/notifications/notification-center";
|
import { NotificationCenter } from "@/components/notifications/notification-center";
|
||||||
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
|
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ export function Topbar() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-1 items-center justify-end gap-1.5">
|
<div className="flex flex-1 items-center justify-end gap-1.5">
|
||||||
|
<BranchSwitcher />
|
||||||
<SyncStatusIndicator />
|
<SyncStatusIndicator />
|
||||||
<NotificationCenter />
|
<NotificationCenter />
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import * as signalR from "@microsoft/signalr";
|
import * as signalR from "@microsoft/signalr";
|
||||||
import { apiGet, apiPatch, apiPost, ApiClientError } from "@/lib/api/client";
|
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
|
||||||
import { printErrorMessage, printReceipt } from "@/lib/api/print";
|
import { printErrorMessage, printReceipt } from "@/lib/api/print";
|
||||||
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
|
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
|
||||||
import { PosSlipModal } from "@/components/pos/pos-slip-modal";
|
import { PosSlipModal } from "@/components/pos/pos-slip-modal";
|
||||||
@@ -27,6 +27,13 @@ type PaymentRow = {
|
|||||||
amount: string;
|
amount: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BranchPrintSettings = {
|
||||||
|
receiptHeader?: string | null;
|
||||||
|
receiptFooter?: string | null;
|
||||||
|
wifiPassword?: string | null;
|
||||||
|
paperWidthMm?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type PosPayPanelProps = {
|
type PosPayPanelProps = {
|
||||||
cafeId: string;
|
cafeId: string;
|
||||||
numberLocale: string;
|
numberLocale: string;
|
||||||
@@ -47,7 +54,9 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
const [payMessage, setPayMessage] = useState<string | null>(null);
|
const [payMessage, setPayMessage] = useState<string | null>(null);
|
||||||
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
const [receiptOrder, setReceiptOrder] = useState<Order | null>(null);
|
const [receiptOrder, setReceiptOrder] = useState<Order | null>(null);
|
||||||
|
const printSettingsBranchId = receiptOrder?.branchId ?? branchId ?? null;
|
||||||
const [lastPaidOrderId, setLastPaidOrderId] = useState<string | null>(null);
|
const [lastPaidOrderId, setLastPaidOrderId] = useState<string | null>(null);
|
||||||
const [paymentRows, setPaymentRows] = useState<PaymentRow[]>([
|
const [paymentRows, setPaymentRows] = useState<PaymentRow[]>([
|
||||||
{ method: "Cash", amount: "" },
|
{ method: "Cash", amount: "" },
|
||||||
@@ -79,6 +88,16 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
|||||||
enabled: !!cafeId,
|
enabled: !!cafeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: printSettings } = useQuery({
|
||||||
|
queryKey: ["branch-print-settings", cafeId, printSettingsBranchId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiGet<BranchPrintSettings>(
|
||||||
|
`/api/cafes/${cafeId}/branches/${printSettingsBranchId}/print-settings`
|
||||||
|
),
|
||||||
|
enabled: !!cafeId && !!printSettingsBranchId,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const displayedOrders = useMemo(() => {
|
const displayedOrders = useMemo(() => {
|
||||||
if (!filterTableId) return openOrders;
|
if (!filterTableId) return openOrders;
|
||||||
return openOrders.filter((o) => o.tableId === filterTableId);
|
return openOrders.filter((o) => o.tableId === filterTableId);
|
||||||
@@ -149,6 +168,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoyaltyRedeem(0);
|
setLoyaltyRedeem(0);
|
||||||
|
setCancelReason("");
|
||||||
}, [selected?.id]);
|
}, [selected?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -228,23 +248,31 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cancelOrder = useMutation({
|
const cancelOrder = useMutation({
|
||||||
mutationFn: (orderId: string) =>
|
mutationFn: ({ orderId, reason }: { orderId: string; reason: string }) =>
|
||||||
apiPatch(`/api/cafes/${cafeId}/orders/${orderId}/status`, {
|
apiPost(`/api/cafes/${cafeId}/orders/${orderId}/cancel`, {
|
||||||
status: "Cancelled",
|
reason: reason.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setPayMessage(t("cancelOrderSuccess"));
|
setPayMessage(t("cancelOrderSuccess"));
|
||||||
|
setCancelReason("");
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
setSelectedTableId(null);
|
setSelectedTableId(null);
|
||||||
setFilterTableId(null);
|
setFilterTableId(null);
|
||||||
setPaymentRows([{ method: "Cash", amount: "" }]);
|
setPaymentRows([{ method: "Cash", amount: "" }]);
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setPayMessage(
|
if (err instanceof ApiClientError) {
|
||||||
err instanceof ApiClientError ? err.message : t("cancelOrderError")
|
if (err.code === "ORDER_HAS_PAYMENTS") {
|
||||||
);
|
setPayMessage(t("cancelOrderHasPayments"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPayMessage(err.message || t("cancelOrderError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPayMessage(t("cancelOrderError"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -578,6 +606,13 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
|||||||
>
|
>
|
||||||
{t("previewBill")}
|
{t("previewBill")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Input
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.target.value)}
|
||||||
|
placeholder={t("cancelReasonPlaceholder")}
|
||||||
|
className="h-9"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -591,7 +626,7 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
|||||||
confirmLabel: t("cancelOrder"),
|
confirmLabel: t("cancelOrder"),
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
cancelOrder.mutate(selected.id);
|
cancelOrder.mutate({ orderId: selected.id, reason: cancelReason });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cancelOrder.isPending ? "..." : t("cancelOrder")}
|
{cancelOrder.isPending ? "..." : t("cancelOrder")}
|
||||||
@@ -630,6 +665,10 @@ export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPan
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" • ") || undefined
|
.join(" • ") || undefined
|
||||||
}
|
}
|
||||||
|
receiptHeader={printSettings?.receiptHeader}
|
||||||
|
receiptFooter={printSettings?.receiptFooter}
|
||||||
|
wifiPassword={printSettings?.wifiPassword}
|
||||||
|
paperWidthMm={printSettings?.paperWidthMm}
|
||||||
onClose={() => setReceiptOrder(null)}
|
onClose={() => setReceiptOrder(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useTranslations, useLocale } from "next-intl";
|
|||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
LayoutDashboard,
|
|
||||||
Minus,
|
Minus,
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -902,17 +901,6 @@ export function PosScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Dashboard shortcut — only visible to Owner / Manager */}
|
|
||||||
{isManager && (
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="size-4" />
|
|
||||||
<span className="hidden sm:inline">{cafeName}</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Pay mode ──────────────────────────────────────────────────────── */}
|
{/* ── Pay mode ──────────────────────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ type PosSlipModalProps = {
|
|||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
/** Address / phone line shown under the café name on the bill. */
|
/** Address / phone line shown under the café name on the bill. */
|
||||||
tagline?: string;
|
tagline?: string;
|
||||||
|
/** Custom header note from branch print settings (bill only). */
|
||||||
|
receiptHeader?: string | null;
|
||||||
|
/** Custom footer note from branch print settings (bill only). */
|
||||||
|
receiptFooter?: string | null;
|
||||||
|
/** WiFi password printed near the bill footer. */
|
||||||
|
wifiPassword?: string | null;
|
||||||
|
/** Paper width in mm — 58 or 80 (default 80). */
|
||||||
|
paperWidthMm?: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Full order for customer bill */
|
/** Full order for customer bill */
|
||||||
order?: Order;
|
order?: Order;
|
||||||
@@ -39,6 +47,10 @@ export function PosSlipModal({
|
|||||||
cafeName,
|
cafeName,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
tagline,
|
tagline,
|
||||||
|
receiptHeader,
|
||||||
|
receiptFooter,
|
||||||
|
wifiPassword,
|
||||||
|
paperWidthMm,
|
||||||
onClose,
|
onClose,
|
||||||
order,
|
order,
|
||||||
kitchenLines = [],
|
kitchenLines = [],
|
||||||
@@ -103,6 +115,9 @@ export function PosSlipModal({
|
|||||||
cafeName,
|
cafeName,
|
||||||
logoUrl: resolveMediaUrl(logoUrl),
|
logoUrl: resolveMediaUrl(logoUrl),
|
||||||
tagline,
|
tagline,
|
||||||
|
header: receiptHeader?.trim() || undefined,
|
||||||
|
wifi: wifiPassword?.trim() || undefined,
|
||||||
|
paperWidthMm,
|
||||||
title: t("billTitle"),
|
title: t("billTitle"),
|
||||||
date: formattedDate,
|
date: formattedDate,
|
||||||
metaRow,
|
metaRow,
|
||||||
@@ -118,7 +133,7 @@ export function PosSlipModal({
|
|||||||
amount: formatCurrency(p.amount, numberLocale),
|
amount: formatCurrency(p.amount, numberLocale),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
footer: t("thankYou"),
|
footer: receiptFooter?.trim() || t("thankYou"),
|
||||||
locale,
|
locale,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,6 +162,11 @@ export function PosSlipModal({
|
|||||||
{variant === "bill" && tagline && (
|
{variant === "bill" && tagline && (
|
||||||
<div className="text-center text-[10px] text-muted-foreground">{tagline}</div>
|
<div className="text-center text-[10px] text-muted-foreground">{tagline}</div>
|
||||||
)}
|
)}
|
||||||
|
{variant === "bill" && receiptHeader?.trim() && (
|
||||||
|
<div className="whitespace-pre-line text-center text-[11px] font-medium text-foreground/80">
|
||||||
|
{receiptHeader.trim()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mb-1 mt-1.5 border-y border-foreground/60 py-0.5 text-center text-xs font-bold">
|
<div className="mb-1 mt-1.5 border-y border-foreground/60 py-0.5 text-center text-xs font-bold">
|
||||||
{variant === "kitchen" ? t("kitchenTitle") : t("billTitle")}
|
{variant === "kitchen" ? t("kitchenTitle") : t("billTitle")}
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +211,14 @@ export function PosSlipModal({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="receipt-divider" />
|
<div className="receipt-divider" />
|
||||||
<div className="mt-2 text-center text-xs">{t("thankYou")}</div>
|
{wifiPassword?.trim() && (
|
||||||
|
<div className="text-center text-[11px]" dir="ltr">
|
||||||
|
WiFi: {wifiPassword.trim()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 text-center text-xs">
|
||||||
|
{receiptFooter?.trim() || t("thankYou")}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
|
|
||||||
export type PlanId = "Free" | "Pro" | "Business" | "Enterprise";
|
export type PlanId = "Free" | "Pro" | "Business" | "Enterprise";
|
||||||
|
|
||||||
const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"];
|
export const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"];
|
||||||
|
|
||||||
const PRICES: Record<PlanId, number | null> = {
|
export const PRICES: Record<PlanId, number | null> = {
|
||||||
Free: 0,
|
Free: 0,
|
||||||
Pro: 1_490_000,
|
Pro: 1_490_000,
|
||||||
Business: 3_490_000,
|
Business: 3_490_000,
|
||||||
@@ -69,8 +69,8 @@ const FEATURE_MATRIX: FeatureRow[] = [
|
|||||||
key: "branches",
|
key: "branches",
|
||||||
cells: {
|
cells: {
|
||||||
Free: { kind: "limit", value: 1 },
|
Free: { kind: "limit", value: 1 },
|
||||||
Pro: { kind: "limit", value: 1 },
|
Pro: { kind: "limit", value: 3 },
|
||||||
Business: { kind: "limit", value: 5 },
|
Business: { kind: "limit", value: null },
|
||||||
Enterprise: { kind: "limit", value: null },
|
Enterprise: { kind: "limit", value: null },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "@/i18n/routing";
|
||||||
|
import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react";
|
||||||
|
import { apiGet, apiPost } from "@/lib/api/client";
|
||||||
|
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||||
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
|
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
|
import { PRICES, type PlanId } from "@/components/settings/plan-comparison";
|
||||||
|
|
||||||
|
type SubscribeResponse = {
|
||||||
|
paymentId: string;
|
||||||
|
paymentUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PaymentMethod = {
|
||||||
|
id: string;
|
||||||
|
displayNameFa: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BILLABLE_PLANS: PlanId[] = ["Pro", "Business"];
|
||||||
|
const MONTH_OPTIONS = [1, 3, 6, 12];
|
||||||
|
|
||||||
|
export function CheckoutScreen() {
|
||||||
|
const t = useTranslations("subscription");
|
||||||
|
const tc = useTranslations("subscription.checkout");
|
||||||
|
const tPlans = useTranslations("settings.plans");
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const cafeId = user?.cafeId;
|
||||||
|
const role = user?.role;
|
||||||
|
|
||||||
|
const planParam = searchParams.get("plan") as PlanId | null;
|
||||||
|
const isBillable = !!planParam && BILLABLE_PLANS.includes(planParam);
|
||||||
|
const plan = (isBillable ? planParam : "Pro") as PlanId;
|
||||||
|
|
||||||
|
const [months, setMonths] = useState(1);
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState("");
|
||||||
|
|
||||||
|
const numberLocale =
|
||||||
|
typeof document !== "undefined" && document.documentElement.lang === "en"
|
||||||
|
? "en-US"
|
||||||
|
: "fa-IR";
|
||||||
|
const isRtl = numberLocale !== "en-US";
|
||||||
|
|
||||||
|
const cafeName = useMemo(() => {
|
||||||
|
if (!user) return "";
|
||||||
|
const membership = user.memberships?.find((m) => m.cafeId === user.cafeId);
|
||||||
|
return membership?.cafeName ?? "";
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const { data: paymentMethods = [] } = useQuery({
|
||||||
|
queryKey: ["billing-payment-methods", cafeId],
|
||||||
|
queryFn: () => apiGet<PaymentMethod[]>("/api/billing/payment-methods"),
|
||||||
|
enabled: !!cafeId && isCafeOwner(role),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!paymentMethod && paymentMethods.length > 0) {
|
||||||
|
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
|
||||||
|
setPaymentMethod(def.id);
|
||||||
|
}
|
||||||
|
}, [paymentMethods, paymentMethod]);
|
||||||
|
|
||||||
|
const subscribe = useMutation({
|
||||||
|
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
|
||||||
|
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
window.location.href = data.paymentUrl;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cafeId) return null;
|
||||||
|
|
||||||
|
if (!isCafeOwner(role)) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title={tc("title")} subtitle={tc("subtitle")} />
|
||||||
|
<p className="text-sm text-muted-foreground">{t("ownerOnly")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBillable) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title={tc("title")} subtitle={tc("subtitle")} />
|
||||||
|
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||||
|
<CardContent className="space-y-4 py-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">{tc("invalidPlan")}</p>
|
||||||
|
<Button variant="outline" onClick={() => router.push("/subscription")}>
|
||||||
|
{tc("backToPlans")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitPrice = PRICES[plan] ?? 0;
|
||||||
|
const subtotal = unitPrice * months;
|
||||||
|
const total = subtotal;
|
||||||
|
const planName = tPlans(`names.${plan}`);
|
||||||
|
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
||||||
|
const issuedAt = new Date().toLocaleDateString(numberLocale);
|
||||||
|
const invoiceNo = `MZ-${Date.now().toString().slice(-8)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title={tc("title")}
|
||||||
|
subtitle={tc("subtitle")}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => router.push("/subscription")}
|
||||||
|
>
|
||||||
|
<BackIcon className="h-4 w-4" aria-hidden />
|
||||||
|
{tc("backToPlans")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Factor / invoice */}
|
||||||
|
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
|
||||||
|
{/* Invoice header */}
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/80 bg-muted/30 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||||
|
{tc("invoiceLabel")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-base font-semibold text-foreground">
|
||||||
|
{cafeName || tc("invoiceLabel")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl className="text-end text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<dt>{tc("invoiceNo")}:</dt>
|
||||||
|
<dd className="font-medium text-foreground" dir="ltr">
|
||||||
|
{invoiceNo}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center justify-end gap-1.5">
|
||||||
|
<dt>{tc("issuedAt")}:</dt>
|
||||||
|
<dd className="font-medium text-foreground">{issuedAt}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6 px-5 py-5">
|
||||||
|
{/* Billing period selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">{tc("billingPeriod")}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{MONTH_OPTIONS.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMonths(m)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-3.5 py-2 text-sm transition active:scale-[0.98]",
|
||||||
|
months === m
|
||||||
|
? "border-[#0F6E56] bg-[#E1F5EE] font-medium text-[#0F6E56]"
|
||||||
|
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tc("monthsCount", { count: formatNumber(m, numberLocale) })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line items */}
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border/60">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border/60 bg-muted/20 text-[11px] uppercase tracking-[0.06em] text-muted-foreground">
|
||||||
|
<th className="px-4 py-2.5 text-start font-medium">{tc("description")}</th>
|
||||||
|
<th className="px-4 py-2.5 text-center font-medium">{tc("qty")}</th>
|
||||||
|
<th className="px-4 py-2.5 text-end font-medium">{tc("unitPrice")}</th>
|
||||||
|
<th className="px-4 py-2.5 text-end font-medium">{tc("amount")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-border/40">
|
||||||
|
<td className="px-4 py-3 text-start">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{tc("planLine", { plan: planName })}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center text-muted-foreground">
|
||||||
|
{tc("monthsCount", { count: formatNumber(months, numberLocale) })}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-end text-muted-foreground" dir="ltr">
|
||||||
|
{formatCurrency(unitPrice, numberLocale)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-end font-medium text-foreground" dir="ltr">
|
||||||
|
{formatCurrency(subtotal, numberLocale)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<span>{tc("subtotal")}</span>
|
||||||
|
<span dir="ltr">{formatCurrency(subtotal, numberLocale)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-border/60 pt-2.5 text-base font-semibold text-foreground">
|
||||||
|
<span>{tc("total")}</span>
|
||||||
|
<span className="text-[#0F6E56]" dir="ltr">
|
||||||
|
{formatCurrency(total, numberLocale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment method */}
|
||||||
|
{paymentMethods.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">{t("paymentMethod")}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{paymentMethods.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaymentMethod(m.id)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-3 py-2 text-sm transition active:scale-[0.98]",
|
||||||
|
paymentMethod === m.id
|
||||||
|
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||||
|
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{m.displayNameFa}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Pay action */}
|
||||||
|
<div className="flex flex-col gap-3 border-t border-border/80 bg-muted/20 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-[#0F6E56]" aria-hidden />
|
||||||
|
{tc("secureNote")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||||
|
disabled={subscribe.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
subscribe.mutate({
|
||||||
|
planTier: plan,
|
||||||
|
months,
|
||||||
|
paymentMethod: paymentMethod || paymentMethods[0]?.id || "zarinpal",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{subscribe.isPending
|
||||||
|
? tc("redirecting")
|
||||||
|
: tc("payTotal", { total: formatCurrency(total, numberLocale) })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { apiGet, apiPost } from "@/lib/api/client";
|
import { apiGet, apiPost } from "@/lib/api/client";
|
||||||
import { isCafeOwner } from "@/lib/auth-permissions";
|
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -32,27 +32,16 @@ type BillingStatus = {
|
|||||||
isPlanExpired: boolean;
|
isPlanExpired: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SubscribeResponse = {
|
|
||||||
paymentId: string;
|
|
||||||
paymentUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PaymentMethod = {
|
|
||||||
id: string;
|
|
||||||
displayNameFa: string;
|
|
||||||
isDefault: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SubscriptionScreen() {
|
export function SubscriptionScreen() {
|
||||||
const t = useTranslations("subscription");
|
const t = useTranslations("subscription");
|
||||||
const tSettings = useTranslations("settings");
|
const tSettings = useTranslations("settings");
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
const role = useAuthStore((s) => s.user?.role);
|
const role = useAuthStore((s) => s.user?.role);
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
const billingRefreshed = useRef(false);
|
const billingRefreshed = useRef(false);
|
||||||
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
|
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
|
||||||
const [paymentMethod, setPaymentMethod] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const billing = searchParams.get("billing");
|
const billing = searchParams.get("billing");
|
||||||
@@ -72,19 +61,6 @@ export function SubscriptionScreen() {
|
|||||||
enabled: !!cafeId,
|
enabled: !!cafeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: paymentMethods = [] } = useQuery({
|
|
||||||
queryKey: ["billing-payment-methods", cafeId],
|
|
||||||
queryFn: () => apiGet<PaymentMethod[]>("/api/billing/payment-methods"),
|
|
||||||
enabled: !!cafeId && isCafeOwner(role),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!paymentMethod && paymentMethods.length > 0) {
|
|
||||||
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
|
|
||||||
setPaymentMethod(def.id);
|
|
||||||
}
|
|
||||||
}, [paymentMethods, paymentMethod]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
|
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
|
||||||
const refresh = localStorage.getItem("meezi_refresh_token");
|
const refresh = localStorage.getItem("meezi_refresh_token");
|
||||||
@@ -98,14 +74,6 @@ export function SubscriptionScreen() {
|
|||||||
.catch(() => notify.warning(tSettings("profile.reloginHint")));
|
.catch(() => notify.warning(tSettings("profile.reloginHint")));
|
||||||
}, [searchParams, setAuth, refetch, tSettings]);
|
}, [searchParams, setAuth, refetch, tSettings]);
|
||||||
|
|
||||||
const subscribe = useMutation({
|
|
||||||
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
|
|
||||||
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
window.location.href = data.paymentUrl;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!cafeId) return null;
|
if (!cafeId) return null;
|
||||||
|
|
||||||
if (!isCafeOwner(role)) {
|
if (!isCafeOwner(role)) {
|
||||||
@@ -187,41 +155,11 @@ export function SubscriptionScreen() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{paymentMethods.length > 0 ? (
|
|
||||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-base">{t("paymentMethod")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
|
||||||
{paymentMethods.map((m) => (
|
|
||||||
<button
|
|
||||||
key={m.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPaymentMethod(m.id)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg border px-3 py-2 text-sm transition active:scale-[0.98]",
|
|
||||||
paymentMethod === m.id
|
|
||||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
|
||||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{m.displayNameFa}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<PlanComparison
|
<PlanComparison
|
||||||
currentPlan={status?.planTier ?? "Free"}
|
currentPlan={status?.planTier ?? "Free"}
|
||||||
onSubscribe={(planTier) =>
|
onSubscribe={(planTier) =>
|
||||||
subscribe.mutate({
|
router.push(`/subscription/checkout?plan=${planTier}`)
|
||||||
planTier,
|
|
||||||
months: 1,
|
|
||||||
paymentMethod: paymentMethod || paymentMethods[0]?.id || "zarinpal",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
isSubscribing={subscribe.isPending}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -44,6 +44,12 @@ export type ThermalSlipData = {
|
|||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
/** Optional tagline / address line under the café name. */
|
/** Optional tagline / address line under the café name. */
|
||||||
tagline?: string;
|
tagline?: string;
|
||||||
|
/** Optional custom header note (from branch print settings) shown under the name. */
|
||||||
|
header?: string;
|
||||||
|
/** Optional WiFi password line printed near the footer. */
|
||||||
|
wifi?: string;
|
||||||
|
/** Paper width in mm — 58 or 80 (default 80). Controls page width + scale. */
|
||||||
|
paperWidthMm?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Absolute URL to the bundled Vazirmatn web-font (same origin). */
|
/** Absolute URL to the bundled Vazirmatn web-font (same origin). */
|
||||||
@@ -104,9 +110,14 @@ export function buildThermalDocument(data: ThermalSlipData): string {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const footerHtml = data.footer
|
const wifiLabel = isRtl ? "وایفای" : "WiFi";
|
||||||
? `<hr class="dashed"><div class="center sm muted">${esc(data.footer)}</div>`
|
const footerInner = [
|
||||||
: "";
|
data.wifi ? `<div class="center sm">${wifiLabel}: ${esc(data.wifi)}</div>` : "",
|
||||||
|
data.footer ? `<div class="center sm muted">${esc(data.footer)}</div>` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("");
|
||||||
|
const footerHtml = footerInner ? `<hr class="dashed">${footerInner}` : "";
|
||||||
|
|
||||||
const logoHtml = data.logoUrl
|
const logoHtml = data.logoUrl
|
||||||
? `<img class="logo" src="${esc(data.logoUrl)}" alt="">`
|
? `<img class="logo" src="${esc(data.logoUrl)}" alt="">`
|
||||||
@@ -114,6 +125,15 @@ export function buildThermalDocument(data: ThermalSlipData): string {
|
|||||||
const taglineHtml = data.tagline
|
const taglineHtml = data.tagline
|
||||||
? `<div class="center tagline">${esc(data.tagline)}</div>`
|
? `<div class="center tagline">${esc(data.tagline)}</div>`
|
||||||
: "";
|
: "";
|
||||||
|
const headerHtml = data.header
|
||||||
|
? `<div class="center header-note">${esc(data.header)}</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Paper width: 58 mm or 80 mm. Narrower paper gets a slightly smaller base
|
||||||
|
// font so lines don't wrap awkwardly.
|
||||||
|
const widthMm = data.paperWidthMm === 58 ? 58 : 80;
|
||||||
|
const baseFontPt = widthMm === 58 ? 10 : 11.5;
|
||||||
|
const wrapPadMm = widthMm === 58 ? "3mm 3mm 5mm" : "4mm 4.5mm 6mm";
|
||||||
|
|
||||||
const fontUrl = vazirmatnFontUrl();
|
const fontUrl = vazirmatnFontUrl();
|
||||||
|
|
||||||
@@ -132,18 +152,18 @@ export function buildThermalDocument(data: ThermalSlipData): string {
|
|||||||
}
|
}
|
||||||
/* ── Page ─────────────────────────────────────────────────── */
|
/* ── Page ─────────────────────────────────────────────────── */
|
||||||
@page {
|
@page {
|
||||||
/* 80 mm wide; height tracks the content — no blank tail */
|
/* width tracks the configured paper; height tracks content — no blank tail */
|
||||||
size: 80mm auto;
|
size: ${widthMm}mm auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
/* ── Reset ────────────────────────────────────────────────── */
|
/* ── Reset ────────────────────────────────────────────────── */
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
/* ── Root ─────────────────────────────────────────────────── */
|
/* ── Root ─────────────────────────────────────────────────── */
|
||||||
html, body {
|
html, body {
|
||||||
width: 80mm;
|
width: ${widthMm}mm;
|
||||||
direction: ${dir};
|
direction: ${dir};
|
||||||
font-family: 'Vazirmatn', 'Tahoma', 'Arial', sans-serif;
|
font-family: 'Vazirmatn', 'Tahoma', 'Arial', sans-serif;
|
||||||
font-size: 11.5pt;
|
font-size: ${baseFontPt}pt;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: #000;
|
color: #000;
|
||||||
@@ -152,7 +172,7 @@ html, body {
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
/* ── Wrapper ──────────────────────────────────────────────── */
|
/* ── Wrapper ──────────────────────────────────────────────── */
|
||||||
.wrap { padding: 4mm 4.5mm 6mm; }
|
.wrap { padding: ${wrapPadMm}; }
|
||||||
/* ── Branding header ──────────────────────────────────────── */
|
/* ── Branding header ──────────────────────────────────────── */
|
||||||
.logo {
|
.logo {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -174,6 +194,13 @@ html, body {
|
|||||||
color: #444;
|
color: #444;
|
||||||
margin-top: 0.5mm;
|
margin-top: 0.5mm;
|
||||||
}
|
}
|
||||||
|
.header-note {
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
margin-top: 1mm;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
.doc-title {
|
.doc-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
@@ -233,6 +260,7 @@ hr.solid { border: none; border-top: 1px solid #000; margin: 2.5mm 0; }
|
|||||||
${logoHtml}
|
${logoHtml}
|
||||||
<div class="cafe-name">${esc(data.cafeName)}</div>
|
<div class="cafe-name">${esc(data.cafeName)}</div>
|
||||||
${taglineHtml}
|
${taglineHtml}
|
||||||
|
${headerHtml}
|
||||||
<div class="doc-title">${esc(data.title)}</div>
|
<div class="doc-title">${esc(data.title)}</div>
|
||||||
<div class="date">${esc(data.date)}</div>
|
<div class="date">${esc(data.date)}</div>
|
||||||
${data.metaRow ? `<div class="meta">${esc(data.metaRow)}</div>` : ""}
|
${data.metaRow ? `<div class="meta">${esc(data.metaRow)}</div>` : ""}
|
||||||
|
|||||||
@@ -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|.*\\..*).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"cap:sync": "cap sync android",
|
"cap:sync": "cap sync android",
|
||||||
|
|||||||
@@ -135,7 +135,7 @@
|
|||||||
"proPriceNote": "/ month",
|
"proPriceNote": "/ month",
|
||||||
"proDesc": "For growing cafes that need professional features.",
|
"proDesc": "For growing cafes that need professional features.",
|
||||||
"ctaPro": "Get Pro",
|
"ctaPro": "Get Pro",
|
||||||
"p1": "1 branch — unlimited orders",
|
"p1": "3 branches — unlimited orders",
|
||||||
"p2": "3 POS terminals",
|
"p2": "3 POS terminals",
|
||||||
"p3": "Full POS & kitchen KDS",
|
"p3": "Full POS & kitchen KDS",
|
||||||
"p4": "Full analytics & reports",
|
"p4": "Full analytics & reports",
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
"businessPriceNote": "/ month",
|
"businessPriceNote": "/ month",
|
||||||
"businessDesc": "For restaurants and multi-branch chains.",
|
"businessDesc": "For restaurants and multi-branch chains.",
|
||||||
"ctaBusiness": "Get Business",
|
"ctaBusiness": "Get Business",
|
||||||
"b1": "Up to 5 branches — unlimited orders",
|
"b1": "Unlimited branches — unlimited orders",
|
||||||
"b2": "Unlimited terminals",
|
"b2": "Unlimited terminals",
|
||||||
"b3": "HR module & shift management",
|
"b3": "HR module & shift management",
|
||||||
"b4": "Delivery platform integration",
|
"b4": "Delivery platform integration",
|
||||||
|
|||||||
@@ -135,7 +135,7 @@
|
|||||||
"proPriceNote": "تومان / ماه",
|
"proPriceNote": "تومان / ماه",
|
||||||
"proDesc": "برای کافههای در حال رشد با نیاز به امکانات حرفهای.",
|
"proDesc": "برای کافههای در حال رشد با نیاز به امکانات حرفهای.",
|
||||||
"ctaPro": "خرید پرو",
|
"ctaPro": "خرید پرو",
|
||||||
"p1": "۱ شعبه — سفارش نامحدود",
|
"p1": "۳ شعبه — سفارش نامحدود",
|
||||||
"p2": "۳ ترمینال صندوق",
|
"p2": "۳ ترمینال صندوق",
|
||||||
"p3": "POS کامل و آشپزخانه KDS",
|
"p3": "POS کامل و آشپزخانه KDS",
|
||||||
"p4": "گزارشهای کامل و تحلیلی",
|
"p4": "گزارشهای کامل و تحلیلی",
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
"businessPriceNote": "تومان / ماه",
|
"businessPriceNote": "تومان / ماه",
|
||||||
"businessDesc": "برای رستورانها و زنجیرههای چند شعبهای.",
|
"businessDesc": "برای رستورانها و زنجیرههای چند شعبهای.",
|
||||||
"ctaBusiness": "خرید بیزنس",
|
"ctaBusiness": "خرید بیزنس",
|
||||||
"b1": "تا ۵ شعبه — سفارش نامحدود",
|
"b1": "شعبه نامحدود — سفارش نامحدود",
|
||||||
"b2": "ترمینال نامحدود",
|
"b2": "ترمینال نامحدود",
|
||||||
"b3": "ماژول منابع انسانی و شیفت",
|
"b3": "ماژول منابع انسانی و شیفت",
|
||||||
"b4": "یکپارچگی اسنپفود / پیک",
|
"b4": "یکپارچگی اسنپفود / پیک",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3010",
|
"dev": "next dev -p 3010",
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"start": "next start -p 3010",
|
"start": "next start -p 3010",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user