Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5a6b1b68d | |||
| f813cc4854 | |||
| 024a455ab3 | |||
| f687178238 | |||
| dc07eb9594 | |||
| 5e980cdfc0 | |||
| 665e3ca279 | |||
| c3ca39ed15 | |||
| dac59cd180 | |||
| c3ea07d6e4 | |||
| aea1d20fdc | |||
| 9f002433c7 | |||
| 5ba09c2ef1 | |||
| 631cac8c3c | |||
| 1a6a0dc495 | |||
| ffdc218e20 | |||
| 260429afba | |||
| ae5896d440 | |||
| aec5b21f98 | |||
| 57c83185da | |||
| cd1af30bbc | |||
| 38e3f6a5a2 | |||
| 5ae350e25b | |||
| 255695e8ae | |||
| a4975cdb2d | |||
| 639d5c305e | |||
| d0117f3171 | |||
| 2c15ae0062 | |||
| 861b762e18 | |||
| 234649c65e | |||
| a9222590ac | |||
| aec68eff34 | |||
| 345ae0a4b5 | |||
| 51e422272d | |||
| 2850ed8ed7 | |||
| 86bbefb9e3 | |||
| 8ca2cae988 | |||
| 09c55669ca | |||
| 639573dfde | |||
| b6e4f83035 | |||
| e8cd6d3282 | |||
| 62bd7a12f5 |
+10
-2
@@ -68,6 +68,14 @@ REDIS_PORT=6381
|
|||||||
# ── Migrations ────────────────────────────────────────────────────────────────
|
# ── Migrations ────────────────────────────────────────────────────────────────
|
||||||
RUN_MIGRATIONS=true
|
RUN_MIGRATIONS=true
|
||||||
|
|
||||||
|
# ── System admin seed (admin panel login) ─────────────────────────────────────
|
||||||
|
# On every boot the seeder ensures this admin exists with these credentials.
|
||||||
|
# Username defaults to "admin" if not set. Password is required to enable
|
||||||
|
# password login — leave blank to force OTP-only login.
|
||||||
|
SEED_ADMIN_PHONE=09190345606
|
||||||
|
SEED_ADMIN_USERNAME=admin
|
||||||
|
SEED_ADMIN_PASSWORD=change-me-strong-admin-password
|
||||||
|
|
||||||
# ── Payment: ZarinPal ─────────────────────────────────────────────────────────
|
# ── Payment: ZarinPal ─────────────────────────────────────────────────────────
|
||||||
# Get your merchant ID from: https://panel.zarinpal.com → API → MerchantID
|
# Get your merchant ID from: https://panel.zarinpal.com → API → MerchantID
|
||||||
ZARINPAL_MERCHANT_ID=
|
ZARINPAL_MERCHANT_ID=
|
||||||
@@ -81,5 +89,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
|
||||||
|
|||||||
+138
-54
@@ -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/ --strict-ssl=false
|
||||||
|
|
||||||
- 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/ --strict-ssl=false
|
||||||
|
|
||||||
- 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/ --strict-ssl=false
|
||||||
|
|
||||||
- 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/ --strict-ssl=false
|
||||||
|
|
||||||
- name: TypeScript check
|
- name: TypeScript check
|
||||||
working-directory: web/koja
|
working-directory: web/koja
|
||||||
@@ -313,46 +310,133 @@ jobs:
|
|||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||||
|
|
||||||
- name: Start main services
|
- name: Stop old app containers
|
||||||
|
# The existing containers were created before compose labels were added,
|
||||||
|
# so Compose can't claim them and hits a name conflict on 'up'.
|
||||||
|
# This step removes only meezi's own 6 app containers — never touches
|
||||||
|
# postgres, redis, or any other project's containers.
|
||||||
run: |
|
run: |
|
||||||
docker compose up -d \
|
for name in meezi-api meezi-web meezi-website meezi-koja meezi-admin-api meezi-admin-web; do
|
||||||
--remove-orphans \
|
docker stop "$name" 2>/dev/null || true
|
||||||
--no-deps \
|
docker rm "$name" 2>/dev/null || true
|
||||||
postgres redis api web website koja
|
done
|
||||||
|
|
||||||
- name: Start admin services
|
- name: Attach infrastructure to meezi network
|
||||||
|
# postgres/redis may be on a different network (created before name:meezi
|
||||||
|
# was in the compose file). Disconnect/reconnect with service-name aliases
|
||||||
|
# so the API can resolve "Host=postgres" and "redis:6379".
|
||||||
|
# App containers are stopped at this point so the brief disconnect is safe.
|
||||||
|
run: |
|
||||||
|
docker network inspect meezi_default >/dev/null 2>&1 \
|
||||||
|
|| docker network create meezi_default
|
||||||
|
docker network disconnect meezi_default meezi-db 2>/dev/null || true
|
||||||
|
docker network disconnect meezi_default meezi-redis 2>/dev/null || true
|
||||||
|
docker network connect --alias postgres meezi_default meezi-db
|
||||||
|
docker network connect --alias redis meezi_default meezi-redis
|
||||||
|
echo "=== infra network state ==="
|
||||||
|
docker inspect meezi-db --format='meezi-db networks={{json .NetworkSettings.Networks}}' 2>&1 || true
|
||||||
|
docker inspect meezi-redis --format='meezi-redis networks={{json .NetworkSettings.Networks}}' 2>&1 || true
|
||||||
|
|
||||||
|
- name: Start API
|
||||||
|
# --no-deps skips all depends_on checks so compose starts api immediately
|
||||||
|
# without trying to verify postgres/redis health (they're not compose-managed).
|
||||||
|
run: docker compose up -d --no-deps api
|
||||||
|
|
||||||
|
- name: Wait for API healthy
|
||||||
|
# Poll ourselves so we can detect crashes early and print logs before
|
||||||
|
# restart-policy smothers them. Mirrors healthcheck: start_period=40s,
|
||||||
|
# interval=10s, retries=12 → up to 3 min total.
|
||||||
|
# Also checks RestartCount: restart:unless-stopped hides crashes behind
|
||||||
|
# rapid restarts, so state=exited is fleeting — a rising count tells us.
|
||||||
|
run: |
|
||||||
|
echo "Waiting for meezi-api (up to 3 min)..."
|
||||||
|
for i in $(seq 1 36); do
|
||||||
|
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' meezi-api 2>/dev/null || echo "missing")
|
||||||
|
STATE=$(docker inspect --format='{{.State.Status}}' meezi-api 2>/dev/null || echo "missing")
|
||||||
|
RESTARTS=$(docker inspect --format='{{.RestartCount}}' meezi-api 2>/dev/null || echo "0")
|
||||||
|
echo " [$i/36] state=$STATE health=$HEALTH restarts=$RESTARTS"
|
||||||
|
[ "$HEALTH" = "healthy" ] && echo "✅ meezi-api healthy" && break
|
||||||
|
if [ "$STATE" = "exited" ] || [ "$STATE" = "dead" ]; then
|
||||||
|
echo "❌ meezi-api crashed (state=$STATE) — logs:"
|
||||||
|
docker logs meezi-api 2>&1 | tail -120
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$RESTARTS" -gt 1 ]; then
|
||||||
|
echo "❌ meezi-api crash-loop (restarts=$RESTARTS) — logs:"
|
||||||
|
docker logs meezi-api 2>&1 | tail -120
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[ "$i" = "36" ] && echo "❌ meezi-api timeout (3 min)" \
|
||||||
|
&& docker logs meezi-api 2>&1 | tail -80 && exit 1
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Start web services
|
||||||
|
# API is healthy at this point; start the three Next.js frontends.
|
||||||
|
run: docker compose up -d --no-deps web website koja
|
||||||
|
|
||||||
|
- name: Start admin API
|
||||||
run: |
|
run: |
|
||||||
docker compose \
|
docker compose \
|
||||||
-f docker-compose.yml \
|
-f docker-compose.yml \
|
||||||
-f docker-compose.admin.yml \
|
-f docker-compose.admin.yml \
|
||||||
up -d \
|
up -d --no-deps admin-api
|
||||||
--no-deps \
|
|
||||||
admin-api admin-web
|
|
||||||
|
|
||||||
- name: Wait for main API healthy
|
|
||||||
run: |
|
|
||||||
for i in $(seq 1 24); do
|
|
||||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' meezi-api 2>/dev/null || echo "missing")
|
|
||||||
echo " [$i/24] $STATUS"
|
|
||||||
[ "$STATUS" = "healthy" ] && echo "✅ meezi-api healthy" && break
|
|
||||||
[ "$i" = "24" ] && echo "❌ meezi-api timeout" && docker compose logs --tail=40 api && exit 1
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Wait for admin API healthy
|
- name: Wait for admin API healthy
|
||||||
run: |
|
run: |
|
||||||
for i in $(seq 1 24); do
|
echo "Waiting for meezi-admin-api (up to 3 min)..."
|
||||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' meezi-admin-api 2>/dev/null || echo "missing")
|
for i in $(seq 1 36); do
|
||||||
echo " [$i/24] $STATUS"
|
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' meezi-admin-api 2>/dev/null || echo "missing")
|
||||||
[ "$STATUS" = "healthy" ] && echo "✅ meezi-admin-api healthy" && break
|
STATE=$(docker inspect --format='{{.State.Status}}' meezi-admin-api 2>/dev/null || echo "missing")
|
||||||
[ "$i" = "24" ] && echo "❌ meezi-admin-api timeout" && docker compose -f docker-compose.yml -f docker-compose.admin.yml logs --tail=40 admin-api && exit 1
|
RESTARTS=$(docker inspect --format='{{.RestartCount}}' meezi-admin-api 2>/dev/null || echo "0")
|
||||||
|
echo " [$i/36] state=$STATE health=$HEALTH restarts=$RESTARTS"
|
||||||
|
[ "$HEALTH" = "healthy" ] && echo "✅ meezi-admin-api healthy" && break
|
||||||
|
if [ "$STATE" = "exited" ] || [ "$STATE" = "dead" ]; then
|
||||||
|
echo "❌ meezi-admin-api crashed (state=$STATE) — logs:"
|
||||||
|
docker logs meezi-admin-api 2>&1 | tail -80
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$RESTARTS" -gt 1 ]; then
|
||||||
|
echo "❌ meezi-admin-api crash-loop (restarts=$RESTARTS) — logs:"
|
||||||
|
docker logs meezi-admin-api 2>&1 | tail -80
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[ "$i" = "36" ] && echo "❌ meezi-admin-api timeout (3 min)" \
|
||||||
|
&& docker logs meezi-admin-api 2>&1 | tail -80 && exit 1
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Start admin web
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.yml \
|
||||||
|
-f docker-compose.admin.yml \
|
||||||
|
up -d --no-deps admin-web
|
||||||
|
|
||||||
- name: Show all running containers
|
- name: Show all running containers
|
||||||
if: always()
|
if: always()
|
||||||
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
|
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
|
||||||
|
|
||||||
- name: Prune old images
|
- name: Dump logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "=== meezi-api logs ==="
|
||||||
|
docker logs meezi-api --tail=120 2>&1 || true
|
||||||
|
echo "=== meezi-admin-api logs ==="
|
||||||
|
docker logs meezi-admin-api --tail=80 2>&1 || true
|
||||||
|
echo "=== meezi_default network ==="
|
||||||
|
docker network inspect meezi_default 2>&1 || true
|
||||||
|
echo "=== meezi-db network state ==="
|
||||||
|
docker inspect meezi-db --format='{{json .NetworkSettings.Networks}}' 2>&1 || true
|
||||||
|
echo "=== meezi-redis network state ==="
|
||||||
|
docker inspect meezi-redis --format='{{json .NetworkSettings.Networks}}' 2>&1 || true
|
||||||
|
|
||||||
|
- name: Prune dangling images
|
||||||
if: success()
|
if: success()
|
||||||
run: docker image prune -f
|
run: |
|
||||||
|
# Remove untagged (<none>) images left over from this and previous builds.
|
||||||
|
# --filter dangling=true only removes images with no tags; never touches
|
||||||
|
# other projects' named images (soroushasadi-site, drsousan, etc.).
|
||||||
|
docker image prune -f
|
||||||
|
echo "Disk after prune:"
|
||||||
|
df -h /
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
registry=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
|
strict-ssl=false
|
||||||
|
legacy-peer-deps=true
|
||||||
@@ -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:
|
||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
|
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
RUN_MIGRATIONS: "false"
|
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
|
||||||
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
||||||
ConnectionStrings__Redis: redis:6379
|
ConnectionStrings__Redis: redis:6379
|
||||||
Jwt__Key: "${JWT_KEY:-dev-jwt-key-CHANGE-THIS-IN-PRODUCTION-min32chars}"
|
Jwt__Key: "${JWT_KEY:-dev-jwt-key-CHANGE-THIS-IN-PRODUCTION-min32chars}"
|
||||||
@@ -36,6 +36,9 @@ services:
|
|||||||
Cors__Origins__1: "${CORS_ORIGIN_0:-http://localhost:3101}"
|
Cors__Origins__1: "${CORS_ORIGIN_0:-http://localhost:3101}"
|
||||||
Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}"
|
Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}"
|
||||||
Kavenegar__SenderNumber: "${KAVENEGAR_SENDER:-90005671}"
|
Kavenegar__SenderNumber: "${KAVENEGAR_SENDER:-90005671}"
|
||||||
|
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
|
||||||
|
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
|
||||||
|
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
|
||||||
ports:
|
ports:
|
||||||
- "${ADMIN_API_PORT:-5081}:8080"
|
- "${ADMIN_API_PORT:-5081}:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -52,8 +55,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.
|
||||||
|
|||||||
+22
-10
@@ -1,5 +1,14 @@
|
|||||||
|
name: meezi # Lock project name — prevents runner workspace from overriding it
|
||||||
|
|
||||||
# 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 +27,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 +45,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 +66,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:
|
||||||
@@ -85,6 +94,9 @@ services:
|
|||||||
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
||||||
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
||||||
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
||||||
|
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
|
||||||
|
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
|
||||||
|
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-5080}:8080"
|
- "${API_PORT:-5080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -103,8 +115,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 +136,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 +160,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,27 +1,40 @@
|
|||||||
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine
|
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
||||||
|
|
||||||
|
# ==================== DEPS STAGE ====================
|
||||||
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/
|
|
||||||
# Install deps then ensure Alpine (musl) SWC binary is present
|
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
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)") \
|
RUN npm ci --legacy-peer-deps --ignore-scripts \
|
||||||
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
|
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
|
||||||
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|
||||||
|| npm install --no-save --ignore-scripts --registry ${NPM_REGISTRY} \
|
|| npm install --no-save --ignore-scripts \
|
||||||
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
"@next/swc-linux-x64-musl@${NEXT_VER}"
|
"@next/swc-linux-x64-musl@${NEXT_VER}"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== BUILDER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:5081
|
ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:5081
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
|
ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY web/admin/ .
|
COPY web/admin/ .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== RUNNER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS runner
|
FROM ${NODE_IMAGE} AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -38,5 +51,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -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,10 +1,10 @@
|
|||||||
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} --strict-ssl=false
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
+24
-4
@@ -1,22 +1,41 @@
|
|||||||
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine
|
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
||||||
|
|
||||||
|
# ==================== DEPS STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS deps
|
FROM ${NODE_IMAGE} AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY web/dashboard/package*.json ./
|
|
||||||
ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/
|
|
||||||
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
|
|
||||||
|
|
||||||
|
COPY web/dashboard/package*.json ./
|
||||||
|
|
||||||
|
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
|
|
||||||
|
# Use npm ci + ensure musl SWC binary (important on Alpine)
|
||||||
|
RUN npm ci --legacy-peer-deps --ignore-scripts \
|
||||||
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
|
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
|
||||||
|
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|
||||||
|
|| npm install --no-save --ignore-scripts \
|
||||||
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
|
"@next/swc-linux-x64-musl@${NEXT_VER}"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== BUILDER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_API_URL=http://localhost:5080
|
ARG NEXT_PUBLIC_API_URL=http://localhost:5080
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY web/dashboard/ .
|
COPY web/dashboard/ .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== RUNNER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS runner
|
FROM ${NODE_IMAGE} AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -33,5 +52,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -1,29 +1,40 @@
|
|||||||
ARG NODE_IMAGE=docker-mirror.liara.ir/library/node:20-alpine
|
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
|
||||||
|
|
||||||
|
# ==================== DEPS STAGE ====================
|
||||||
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/
|
|
||||||
# Install deps then ensure Alpine (musl) SWC binary is present
|
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
|
||||||
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)") \
|
RUN npm ci --legacy-peer-deps --ignore-scripts \
|
||||||
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
|
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
|
||||||
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|
||||||
|| npm install --no-save --ignore-scripts --registry ${NPM_REGISTRY} \
|
|| npm install --no-save --ignore-scripts \
|
||||||
|
--registry ${NPM_REGISTRY} \
|
||||||
|
--strict-ssl=false \
|
||||||
"@next/swc-linux-x64-musl@${NEXT_VER}"
|
"@next/swc-linux-x64-musl@${NEXT_VER}"
|
||||||
|
|
||||||
|
# ==================== BUILDER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS builder
|
FROM ${NODE_IMAGE} AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG MEEZI_API_URL=http://api:8080
|
ARG MEEZI_API_URL=http://api:8080
|
||||||
ENV MEEZI_API_URL=$MEEZI_API_URL
|
|
||||||
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3010
|
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3010
|
||||||
|
|
||||||
|
ENV MEEZI_API_URL=$MEEZI_API_URL
|
||||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY web/website/ .
|
COPY web/website/ .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# ==================== RUNNER STAGE ====================
|
||||||
FROM ${NODE_IMAGE} AS runner
|
FROM ${NODE_IMAGE} AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -41,5 +52,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|||||||
COPY --from=builder /app/src/content ./src/content
|
COPY --from=builder /app/src/content ./src/content
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -39,6 +38,26 @@ public class AuthController : ControllerBase
|
|||||||
_verifyRegisterValidator = verifyRegisterValidator;
|
_verifyRegisterValidator = verifyRegisterValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> LoginWithPassword(
|
||||||
|
[FromBody] LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
return BadRequest(ValidationError("Username and password are required."));
|
||||||
|
|
||||||
|
var (success, data, code, message, choices) = await _authService.LoginWithPasswordAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!success && code == "CHOOSE_CAFE")
|
||||||
|
return Ok(new ApiResponse<CafeChoicesResponse>(false, choices, new ApiError("CHOOSE_CAFE", "Please select a café to continue.")));
|
||||||
|
|
||||||
|
if (!success)
|
||||||
|
return ErrorResult(code!, message!);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("send-otp")]
|
[HttpPost("send-otp")]
|
||||||
[EnableRateLimiting("auth-otp")]
|
[EnableRateLimiting("auth-otp")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
||||||
@@ -91,6 +110,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)
|
||||||
@@ -173,12 +213,17 @@ public class AuthController : ControllerBase
|
|||||||
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
|
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ApiResponse<object> ValidationError(string message) =>
|
||||||
|
new(false, null, new ApiError("VALIDATION_ERROR", message));
|
||||||
|
|
||||||
private IActionResult ErrorResult(string code, string message) => code switch
|
private IActionResult ErrorResult(string code, string message) => code switch
|
||||||
{
|
{
|
||||||
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
||||||
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();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Meezi.API.Models.Cafes;
|
using Meezi.API.Models.Cafes;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Core.Utilities;
|
||||||
using Meezi.Infrastructure.Branding;
|
using Meezi.Infrastructure.Branding;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -57,6 +58,21 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
if (cafe is null) return NotFoundError();
|
if (cafe is null) return NotFoundError();
|
||||||
|
|
||||||
if (request.Name is not null) cafe.Name = request.Name.Trim();
|
if (request.Name is not null) cafe.Name = request.Name.Trim();
|
||||||
|
|
||||||
|
if (request.Slug is not null)
|
||||||
|
{
|
||||||
|
var newSlug = request.Slug.Trim().ToLowerInvariant();
|
||||||
|
if (!SlugHelper.IsValidSlug(newSlug))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("INVALID_SLUG", "Slug must be 2-80 lowercase letters, digits, or hyphens.")));
|
||||||
|
|
||||||
|
var taken = await _db.Cafes.AnyAsync(c => c.Slug == newSlug && c.Id != cafeId, ct);
|
||||||
|
if (taken)
|
||||||
|
return Conflict(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("SLUG_TAKEN", "This Koja profile address is already in use. Please choose another.")));
|
||||||
|
|
||||||
|
cafe.Slug = newSlug;
|
||||||
|
}
|
||||||
if (request.Phone is not null) cafe.Phone = request.Phone.Trim();
|
if (request.Phone is not null) cafe.Phone = request.Phone.Trim();
|
||||||
if (request.Address is not null) cafe.Address = request.Address.Trim();
|
if (request.Address is not null) cafe.Address = request.Address.Trim();
|
||||||
if (request.City is not null) cafe.City = request.City.Trim();
|
if (request.City is not null) cafe.City = request.City.Trim();
|
||||||
@@ -71,6 +87,21 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
if (request.AllowBranchTaxOverride is bool allowTax)
|
if (request.AllowBranchTaxOverride is bool allowTax)
|
||||||
cafe.AllowBranchTaxOverride = allowTax;
|
cafe.AllowBranchTaxOverride = allowTax;
|
||||||
|
|
||||||
|
// Location: explicit null-clear flag OR new values
|
||||||
|
if (request.ClearLocation)
|
||||||
|
{
|
||||||
|
cafe.Latitude = null;
|
||||||
|
cafe.Longitude = null;
|
||||||
|
}
|
||||||
|
else if (request.Latitude.HasValue && request.Longitude.HasValue)
|
||||||
|
{
|
||||||
|
if (request.Latitude is < -90 or > 90 || request.Longitude is < -180 or > 180)
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("INVALID_LOCATION", "Latitude must be −90…90 and longitude −180…180.")));
|
||||||
|
cafe.Latitude = request.Latitude;
|
||||||
|
cafe.Longitude = request.Longitude;
|
||||||
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe)));
|
return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe)));
|
||||||
}
|
}
|
||||||
@@ -90,5 +121,7 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
cafe.PlanExpiresAt,
|
cafe.PlanExpiresAt,
|
||||||
CafeThemeMapping.FromJson(cafe.ThemeJson),
|
CafeThemeMapping.FromJson(cafe.ThemeJson),
|
||||||
cafe.DefaultTaxRate,
|
cafe.DefaultTaxRate,
|
||||||
cafe.AllowBranchTaxOverride);
|
cafe.AllowBranchTaxOverride,
|
||||||
|
cafe.Latitude,
|
||||||
|
cafe.Longitude);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Shared;
|
||||||
|
|
||||||
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
|
[Route("api/cafes/{cafeId}/demo")]
|
||||||
|
[Authorize]
|
||||||
|
public class DemoSeedController : CafeApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDemoSeedService _demoSeed;
|
||||||
|
|
||||||
|
public DemoSeedController(IDemoSeedService demoSeed)
|
||||||
|
{
|
||||||
|
_demoSeed = demoSeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seeds demo menu, tables, and inventory for any café. Owner-only.</summary>
|
||||||
|
[HttpPost("seed")]
|
||||||
|
public async Task<IActionResult> Seed(
|
||||||
|
string cafeId,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||||
|
|
||||||
|
var result = await _demoSeed.SeedAsync(cafeId, ct);
|
||||||
|
return Ok(new ApiResponse<DemoSeedResult>(true, result));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Hr;
|
using Meezi.API.Models.Hr;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Core.Utilities;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -15,17 +18,20 @@ public class HrController : CafeApiControllerBase
|
|||||||
private readonly IValidator<CreateLeaveRequest> _leaveValidator;
|
private readonly IValidator<CreateLeaveRequest> _leaveValidator;
|
||||||
private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
|
private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
|
||||||
private readonly IValidator<CreateSalaryRequest> _salaryValidator;
|
private readonly IValidator<CreateSalaryRequest> _salaryValidator;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
public HrController(
|
public HrController(
|
||||||
IHrService hr,
|
IHrService hr,
|
||||||
IValidator<CreateLeaveRequest> leaveValidator,
|
IValidator<CreateLeaveRequest> leaveValidator,
|
||||||
IValidator<ReviewLeaveRequest> reviewValidator,
|
IValidator<ReviewLeaveRequest> reviewValidator,
|
||||||
IValidator<CreateSalaryRequest> salaryValidator)
|
IValidator<CreateSalaryRequest> salaryValidator,
|
||||||
|
AppDbContext db)
|
||||||
{
|
{
|
||||||
_hr = hr;
|
_hr = hr;
|
||||||
_leaveValidator = leaveValidator;
|
_leaveValidator = leaveValidator;
|
||||||
_reviewValidator = reviewValidator;
|
_reviewValidator = reviewValidator;
|
||||||
_salaryValidator = salaryValidator;
|
_salaryValidator = salaryValidator;
|
||||||
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("employees")]
|
[HttpGet("employees")]
|
||||||
@@ -202,20 +208,65 @@ public class HrController : CafeApiControllerBase
|
|||||||
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant)
|
/// <summary>Set or update username/password credentials for an employee. Owner/Manager only.</summary>
|
||||||
|
[HttpPut("employees/{employeeId}/credentials")]
|
||||||
|
public async Task<IActionResult> SetCredentials(
|
||||||
|
string cafeId,
|
||||||
|
string employeeId,
|
||||||
|
[FromBody] SetEmployeeCredentialsRequest request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (tenant.UserId == employeeId) return null;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
return EnsureManager(tenant);
|
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
|
var username = request.Username.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(username))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Username is required.", "Username")));
|
||||||
|
|
||||||
|
if (request.Password.Length < 8)
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Password must be at least 8 characters.", "Password")));
|
||||||
|
|
||||||
|
var employee = await _db.Employees
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
||||||
|
|
||||||
|
if (employee is null) return NotFoundError();
|
||||||
|
|
||||||
|
// Check username uniqueness within the cafe (excluding the employee itself)
|
||||||
|
var conflict = await _db.Employees
|
||||||
|
.AnyAsync(e => e.CafeId == cafeId && e.Id != employeeId && e.DeletedAt == null
|
||||||
|
&& e.Username != null && e.Username.ToLower() == username, ct);
|
||||||
|
if (conflict)
|
||||||
|
return Conflict(new ApiResponse<object>(false, null, new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.")));
|
||||||
|
|
||||||
|
employee.Username = username;
|
||||||
|
employee.PasswordHash = PasswordHasher.Hash(request.Password);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IActionResult? EnsureManager(ITenantContext tenant)
|
/// <summary>Remove username/password credentials from an employee. Owner/Manager only.</summary>
|
||||||
|
[HttpDelete("employees/{employeeId}/credentials")]
|
||||||
|
public async Task<IActionResult> RemoveCredentials(
|
||||||
|
string cafeId,
|
||||||
|
string employeeId,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager)
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
return null;
|
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
return new ObjectResult(new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Manager access required.")))
|
var employee = await _db.Employees
|
||||||
{
|
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
||||||
StatusCode = StatusCodes.Status403Forbidden
|
|
||||||
};
|
if (employee is null) return NotFoundError();
|
||||||
|
|
||||||
|
employee.Username = null;
|
||||||
|
employee.PasswordHash = null;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Menu;
|
using Meezi.API.Models.Menu;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Constants;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -15,17 +18,25 @@ public class MenuController : CafeApiControllerBase
|
|||||||
private readonly IMenuAi3dGenerationService _menuAi3d;
|
private readonly IMenuAi3dGenerationService _menuAi3d;
|
||||||
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
||||||
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
private const string CategoryLimitMessage =
|
||||||
|
"محدودیت دستهبندی پلن رایگان (۳ دسته). برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
||||||
|
private const string ItemLimitMessage =
|
||||||
|
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
||||||
|
|
||||||
public MenuController(
|
public MenuController(
|
||||||
IMenuService menuService,
|
IMenuService menuService,
|
||||||
IMenuAi3dGenerationService menuAi3d,
|
IMenuAi3dGenerationService menuAi3d,
|
||||||
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
||||||
IValidator<CreateMenuItemRequest> createItemValidator)
|
IValidator<CreateMenuItemRequest> createItemValidator,
|
||||||
|
AppDbContext db)
|
||||||
{
|
{
|
||||||
_menuService = menuService;
|
_menuService = menuService;
|
||||||
_menuAi3d = menuAi3d;
|
_menuAi3d = menuAi3d;
|
||||||
_createCategoryValidator = createCategoryValidator;
|
_createCategoryValidator = createCategoryValidator;
|
||||||
_createItemValidator = createItemValidator;
|
_createItemValidator = createItemValidator;
|
||||||
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("categories")]
|
[HttpGet("categories")]
|
||||||
@@ -47,6 +58,17 @@ public class MenuController : CafeApiControllerBase
|
|||||||
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
|
var max = PlanLimits.MaxMenuCategories(tier);
|
||||||
|
if (max != int.MaxValue)
|
||||||
|
{
|
||||||
|
var count = await _db.MenuCategories.CountAsync(
|
||||||
|
c => c.CafeId == cafeId && c.DeletedAt == null, cancellationToken);
|
||||||
|
if (count >= max)
|
||||||
|
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("PLAN_LIMIT_REACHED", CategoryLimitMessage)));
|
||||||
|
}
|
||||||
|
|
||||||
var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken);
|
var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken);
|
||||||
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
||||||
}
|
}
|
||||||
@@ -97,6 +119,17 @@ public class MenuController : CafeApiControllerBase
|
|||||||
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
|
var max = PlanLimits.MaxMenuItems(tier);
|
||||||
|
if (max != int.MaxValue)
|
||||||
|
{
|
||||||
|
var count = await _db.MenuItems.CountAsync(
|
||||||
|
i => i.CafeId == cafeId && i.DeletedAt == null, cancellationToken);
|
||||||
|
if (count >= max)
|
||||||
|
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("PLAN_LIMIT_REACHED", ItemLimitMessage)));
|
||||||
|
}
|
||||||
|
|
||||||
var data = await _menuService.CreateItemAsync(cafeId, request, cancellationToken);
|
var data = await _menuService.CreateItemAsync(cafeId, request, cancellationToken);
|
||||||
if (data is null) return NotFoundError("Category not found.");
|
if (data is null) return NotFoundError("Category not found.");
|
||||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -367,4 +367,101 @@ public class PublicController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new ApiResponse<object>(true, null));
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all cafés that have a known location (Latitude/Longitude set).
|
||||||
|
/// Used by the marketing website SVG map to render blinking dots.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("map-markers")]
|
||||||
|
[EnableRateLimiting("public-read")]
|
||||||
|
public async Task<IActionResult> GetMapMarkers(
|
||||||
|
[FromServices] AppDbContext db,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var markers = await db.Cafes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.DeletedAt == null && c.Latitude != null && c.Longitude != null)
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
c.Id,
|
||||||
|
c.Name,
|
||||||
|
c.Slug,
|
||||||
|
c.City,
|
||||||
|
c.Latitude,
|
||||||
|
c.Longitude,
|
||||||
|
c.LogoUrl
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, markers));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns cafés near a given coordinate, sorted by distance ascending.
|
||||||
|
/// Used by Koja guest page to show "nearby cafés" section.
|
||||||
|
/// At most <paramref name="limit"/> results (default 5, max 20).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("nearby")]
|
||||||
|
[EnableRateLimiting("public-read")]
|
||||||
|
public async Task<IActionResult> GetNearbyCafes(
|
||||||
|
[FromQuery] double lat,
|
||||||
|
[FromQuery] double lng,
|
||||||
|
[FromQuery] string? excludeSlug,
|
||||||
|
[FromQuery] int limit,
|
||||||
|
[FromServices] AppDbContext db,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit <= 0 ? 5 : limit, 1, 20);
|
||||||
|
|
||||||
|
// Pull all located cafés from DB (typically small set) and sort in memory with Haversine.
|
||||||
|
var cafes = await db.Cafes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.DeletedAt == null
|
||||||
|
&& c.Latitude != null
|
||||||
|
&& c.Longitude != null
|
||||||
|
&& (excludeSlug == null || c.Slug != excludeSlug))
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
c.Id,
|
||||||
|
c.Name,
|
||||||
|
c.Slug,
|
||||||
|
c.City,
|
||||||
|
c.Latitude,
|
||||||
|
c.Longitude,
|
||||||
|
c.LogoUrl,
|
||||||
|
c.CoverImageUrl
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
static double ToRad(double deg) => deg * Math.PI / 180.0;
|
||||||
|
static double Haversine(double lat1, double lon1, double lat2, double lon2)
|
||||||
|
{
|
||||||
|
const double R = 6371; // km
|
||||||
|
var dLat = ToRad(lat2 - lat1);
|
||||||
|
var dLon = ToRad(lon2 - lon1);
|
||||||
|
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
|
||||||
|
+ Math.Cos(ToRad(lat1)) * Math.Cos(ToRad(lat2))
|
||||||
|
* Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
|
||||||
|
return R * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
var nearby = cafes
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
c.Id,
|
||||||
|
c.Name,
|
||||||
|
c.Slug,
|
||||||
|
c.City,
|
||||||
|
c.Latitude,
|
||||||
|
c.Longitude,
|
||||||
|
c.LogoUrl,
|
||||||
|
c.CoverImageUrl,
|
||||||
|
DistanceKm = Math.Round(Haversine(lat, lng, c.Latitude!.Value, c.Longitude!.Value), 1)
|
||||||
|
})
|
||||||
|
.OrderBy(c => c.DistanceKm)
|
||||||
|
.Take(limit)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, nearby));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
@@ -90,6 +91,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IQueueService, QueueService>();
|
services.AddScoped<IQueueService, QueueService>();
|
||||||
services.AddScoped<IShiftService, ShiftService>();
|
services.AddScoped<IShiftService, ShiftService>();
|
||||||
services.AddScoped<IExpenseService, ExpenseService>();
|
services.AddScoped<IExpenseService, ExpenseService>();
|
||||||
|
services.AddScoped<IDemoSeedService, DemoSeedService>();
|
||||||
services.AddScoped<ReceiptBuilder>();
|
services.AddScoped<ReceiptBuilder>();
|
||||||
services.AddScoped<IPrinterService, NetworkPrinterService>();
|
services.AddScoped<IPrinterService, NetworkPrinterService>();
|
||||||
services.AddHttpClient(nameof(PosDeviceService));
|
services.AddHttpClient(nameof(PosDeviceService));
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -2,14 +2,21 @@ namespace Meezi.API.Models.Auth;
|
|||||||
|
|
||||||
public record SendOtpRequest(string Phone);
|
public record SendOtpRequest(string Phone);
|
||||||
|
|
||||||
|
/// <summary>Username + password login (alternative to OTP). Optional cafeId to scope to a specific café.</summary>
|
||||||
|
public record LoginWithPasswordRequest(string Username, string Password, string? CafeId = null);
|
||||||
|
|
||||||
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
|
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
|
||||||
|
|
||||||
public record RefreshTokenRequest(string RefreshToken);
|
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);
|
/// <param name="Slug">Optional custom Koja slug (e.g. "lamiz-enghelab"). Auto-derived from CafeName if omitted.</param>
|
||||||
|
public record RegisterRequest(string Phone, string CafeName, string? Slug = null);
|
||||||
|
|
||||||
/// <summary>Step 2 of self-registration: verify OTP and create the cafe account.</summary>
|
/// <summary>Step 2 of self-registration: verify OTP and create the cafe account.</summary>
|
||||||
public record VerifyRegisterRequest(string Phone, string Code);
|
public record VerifyRegisterRequest(string Phone, string Code);
|
||||||
@@ -17,6 +24,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 +38,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);
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ public record CafeSettingsDto(
|
|||||||
DateTime? PlanExpiresAt,
|
DateTime? PlanExpiresAt,
|
||||||
CafeThemeDto Theme,
|
CafeThemeDto Theme,
|
||||||
decimal DefaultTaxRate,
|
decimal DefaultTaxRate,
|
||||||
bool AllowBranchTaxOverride);
|
bool AllowBranchTaxOverride,
|
||||||
|
double? Latitude,
|
||||||
|
double? Longitude);
|
||||||
|
|
||||||
public record PatchCafeSettingsRequest(
|
public record PatchCafeSettingsRequest(
|
||||||
string? Name,
|
string? Name,
|
||||||
|
/// <summary>Custom Koja profile slug (e.g. "lamiz-enghelab"). Must be unique across all cafés.</summary>
|
||||||
|
string? Slug,
|
||||||
string? Phone,
|
string? Phone,
|
||||||
string? Address,
|
string? Address,
|
||||||
string? City,
|
string? City,
|
||||||
@@ -28,4 +32,10 @@ public record PatchCafeSettingsRequest(
|
|||||||
string? SnappfoodVendorId,
|
string? SnappfoodVendorId,
|
||||||
CafeThemeDto? Theme,
|
CafeThemeDto? Theme,
|
||||||
decimal? DefaultTaxRate,
|
decimal? DefaultTaxRate,
|
||||||
bool? AllowBranchTaxOverride);
|
bool? AllowBranchTaxOverride,
|
||||||
|
/// <summary>WGS-84 latitude. Send null to clear.</summary>
|
||||||
|
double? Latitude,
|
||||||
|
/// <summary>WGS-84 longitude. Send null to clear.</summary>
|
||||||
|
double? Longitude,
|
||||||
|
/// <summary>When true, Latitude and Longitude are explicitly being cleared (set to null).</summary>
|
||||||
|
bool ClearLocation = false);
|
||||||
|
|||||||
@@ -59,3 +59,6 @@ public record CreateSalaryRequest(
|
|||||||
decimal Deductions);
|
decimal Deductions);
|
||||||
|
|
||||||
public record TodayShiftDto(ShiftType ShiftType, string Label);
|
public record TodayShiftDto(ShiftType ShiftType, string Label);
|
||||||
|
|
||||||
|
/// <summary>Set or update username/password credentials for an employee.</summary>
|
||||||
|
public record SetEmployeeCredentialsRequest(string Username, string Password);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,8 +303,15 @@ public class AuthService : IAuthService
|
|||||||
|
|
||||||
var otp = Random.Shared.Next(100000, 999999).ToString();
|
var otp = Random.Shared.Next(100000, 999999).ToString();
|
||||||
await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
|
await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
|
||||||
// Store the cafe name alongside the OTP so verify-register can create the cafe
|
|
||||||
await redis.StringSetAsync($"reg_meta:{phone}", cafeName, TimeSpan.FromSeconds(OtpTtlSeconds));
|
// Determine the requested slug: use provided slug, or auto-derive from café name.
|
||||||
|
// Format stored: "cafeName||slug" (double-pipe delimiter). Slug may be empty.
|
||||||
|
var requestedSlug = string.IsNullOrWhiteSpace(request.Slug)
|
||||||
|
? Meezi.Core.Utilities.SlugHelper.Slugify(cafeName)
|
||||||
|
: request.Slug.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
var regMeta = $"{cafeName}||{requestedSlug}";
|
||||||
|
await redis.StringSetAsync($"reg_meta:{phone}", regMeta, TimeSpan.FromSeconds(OtpTtlSeconds));
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -295,10 +349,25 @@ public class AuthService : IAuthService
|
|||||||
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
|
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
|
||||||
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
|
||||||
|
|
||||||
var cafeName = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString();
|
var regMetaRaw = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString();
|
||||||
if (string.IsNullOrWhiteSpace(cafeName))
|
if (string.IsNullOrWhiteSpace(regMetaRaw))
|
||||||
return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again.");
|
return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again.");
|
||||||
|
|
||||||
|
// Parse "cafeName||slug" format (double-pipe delimiter)
|
||||||
|
string cafeName;
|
||||||
|
string? requestedSlug;
|
||||||
|
var sepIdx = regMetaRaw.IndexOf("||", StringComparison.Ordinal);
|
||||||
|
if (sepIdx >= 0)
|
||||||
|
{
|
||||||
|
cafeName = regMetaRaw[..sepIdx];
|
||||||
|
requestedSlug = regMetaRaw[(sepIdx + 2)..];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cafeName = regMetaRaw;
|
||||||
|
requestedSlug = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Double-check no owner was created in the meantime (race condition guard)
|
// Double-check no owner was created in the meantime (race condition guard)
|
||||||
var alreadyOwner = await _db.Employees
|
var alreadyOwner = await _db.Employees
|
||||||
.AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
|
.AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
|
||||||
@@ -309,8 +378,8 @@ public class AuthService : IAuthService
|
|||||||
return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");
|
return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique slug
|
// Generate a unique slug (try requested slug first, fall back to random)
|
||||||
var slug = await GenerateUniqueSlugAsync(cancellationToken);
|
var slug = await GenerateUniqueSlugAsync(requestedSlug, cancellationToken);
|
||||||
|
|
||||||
var cafe = new Cafe
|
var cafe = new Cafe
|
||||||
{
|
{
|
||||||
@@ -320,6 +389,15 @@ public class AuthService : IAuthService
|
|||||||
PlanTier = PlanTier.Free,
|
PlanTier = PlanTier.Free,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-create a default main branch so the owner can start using the
|
||||||
|
// dashboard immediately without hitting the "select a branch" gate.
|
||||||
|
var defaultBranch = new Branch
|
||||||
|
{
|
||||||
|
CafeId = cafe.Id,
|
||||||
|
Name = cafeName,
|
||||||
|
IsActive = true,
|
||||||
|
};
|
||||||
|
|
||||||
var owner = new Employee
|
var owner = new Employee
|
||||||
{
|
{
|
||||||
CafeId = cafe.Id,
|
CafeId = cafe.Id,
|
||||||
@@ -329,6 +407,7 @@ public class AuthService : IAuthService
|
|||||||
};
|
};
|
||||||
|
|
||||||
_db.Cafes.Add(cafe);
|
_db.Cafes.Add(cafe);
|
||||||
|
_db.Branches.Add(defaultBranch);
|
||||||
_db.Employees.Add(owner);
|
_db.Employees.Add(owner);
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -341,28 +420,101 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GenerateUniqueSlugAsync(CancellationToken ct)
|
private async Task<string> GenerateUniqueSlugAsync(string? preferred, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Try the preferred/derived slug first
|
||||||
|
if (Meezi.Core.Utilities.SlugHelper.IsValidSlug(preferred))
|
||||||
|
{
|
||||||
|
if (!await _db.Cafes.AnyAsync(c => c.Slug == preferred, ct))
|
||||||
|
return preferred!;
|
||||||
|
|
||||||
|
// Preferred slug is taken — append a short random suffix
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
var candidate = $"{preferred}-{Guid.NewGuid().ToString("N")[..4]}";
|
||||||
|
if (!await _db.Cafes.AnyAsync(c => c.Slug == candidate, ct))
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full random fallback
|
||||||
string slug;
|
string slug;
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
// e.g. "cafe-a3f9b2c"
|
|
||||||
slug = "cafe-" + Guid.NewGuid().ToString("N")[..7];
|
slug = "cafe-" + Guid.NewGuid().ToString("N")[..7];
|
||||||
} while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct));
|
} while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct));
|
||||||
return slug;
|
return slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
|
||||||
|
LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var username = request.Username.Trim();
|
||||||
|
|
||||||
|
var candidates = await _db.Employees
|
||||||
|
.Include(e => e.Cafe)
|
||||||
|
.Where(e => e.Username == username
|
||||||
|
&& e.PasswordHash != null
|
||||||
|
&& e.DeletedAt == null
|
||||||
|
&& e.Cafe.DeletedAt == null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||||
|
|
||||||
|
// Constant-time verification (check all matches to avoid username enumeration)
|
||||||
|
var matched = candidates.Where(e => PasswordHasher.Verify(request.Password, e.PasswordHash!)).ToList();
|
||||||
|
|
||||||
|
if (matched.Count == 0)
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||||
|
|
||||||
|
// Scope to a specific café if requested
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.CafeId))
|
||||||
|
{
|
||||||
|
matched = matched.Where(e => e.CafeId == request.CafeId).ToList();
|
||||||
|
if (matched.Count == 0)
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple cafés — ask frontend to pick one
|
||||||
|
if (matched.Count > 1)
|
||||||
|
{
|
||||||
|
var choices = new CafeChoicesResponse(
|
||||||
|
matched
|
||||||
|
.Where(e => e.Cafe is not null)
|
||||||
|
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
|
||||||
|
.ToList());
|
||||||
|
return (false, null, "CHOOSE_CAFE", null, choices);
|
||||||
|
}
|
||||||
|
|
||||||
|
var employee = matched[0];
|
||||||
|
if (employee.Cafe is null)
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
|
||||||
|
|
||||||
|
var membershipDtos = matched
|
||||||
|
.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, null, cancellationToken);
|
||||||
|
return (true, tokens, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||||
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 +523,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,174 @@
|
|||||||
|
using Meezi.Core.Entities;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Meezi.API.Services;
|
||||||
|
|
||||||
|
public record DemoSeedResult(
|
||||||
|
int CategoriesAdded,
|
||||||
|
int ItemsAdded,
|
||||||
|
int TablesAdded,
|
||||||
|
int IngredientsAdded,
|
||||||
|
bool TaxCreated);
|
||||||
|
|
||||||
|
public interface IDemoSeedService
|
||||||
|
{
|
||||||
|
Task<DemoSeedResult> SeedAsync(string cafeId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DemoSeedService : IDemoSeedService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ILogger<DemoSeedService> _logger;
|
||||||
|
|
||||||
|
public DemoSeedService(AppDbContext db, ILogger<DemoSeedService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DemoSeedResult> SeedAsync(string cafeId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// 1. Ensure 9% default tax
|
||||||
|
var taxId = $"{cafeId}_demo_tax";
|
||||||
|
var taxCreated = false;
|
||||||
|
if (!await _db.Taxes.AnyAsync(t => t.CafeId == cafeId && t.IsDefault, ct))
|
||||||
|
{
|
||||||
|
_db.Taxes.Add(new Tax
|
||||||
|
{
|
||||||
|
Id = taxId,
|
||||||
|
CafeId = cafeId,
|
||||||
|
Name = "مالیات ارزش افزوده",
|
||||||
|
Rate = 9,
|
||||||
|
IsDefault = true,
|
||||||
|
IsRequired = true,
|
||||||
|
IsCompound = false
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
taxCreated = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
taxId = await _db.Taxes
|
||||||
|
.Where(t => t.CafeId == cafeId && t.IsDefault)
|
||||||
|
.Select(t => t.Id)
|
||||||
|
.FirstAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Seed menu (categories + items) using café-agnostic seeder.
|
||||||
|
// useScopedIds=true prefixes all IDs with cafeId so multiple cafés
|
||||||
|
// can each have their own demo menu without primary-key collisions.
|
||||||
|
var beforeCats = await _db.MenuCategories.CountAsync(c => c.CafeId == cafeId, ct);
|
||||||
|
var beforeItems = await _db.MenuItems.CountAsync(i => i.CafeId == cafeId, ct);
|
||||||
|
await DemoMenuSeeder.EnsureMenuAsync(_db, cafeId, taxId, _logger, useScopedIds: true);
|
||||||
|
var afterCats = await _db.MenuCategories.CountAsync(c => c.CafeId == cafeId, ct);
|
||||||
|
var afterItems = await _db.MenuItems.CountAsync(i => i.CafeId == cafeId, ct);
|
||||||
|
|
||||||
|
// 3. Seed ingredients if warehouse is empty
|
||||||
|
var ingredientsAdded = 0;
|
||||||
|
if (!await _db.Ingredients.AnyAsync(i => i.CafeId == cafeId, ct))
|
||||||
|
{
|
||||||
|
var demoIngredients = BuildDemoIngredients(cafeId);
|
||||||
|
_db.Ingredients.AddRange(demoIngredients);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
ingredientsAdded = demoIngredients.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Seed 10 tables if no tables exist for this café's first active branch
|
||||||
|
var tablesAdded = 0;
|
||||||
|
if (!await _db.Tables.AnyAsync(t => t.CafeId == cafeId, ct))
|
||||||
|
{
|
||||||
|
var branchId = await _db.Branches
|
||||||
|
.Where(b => b.CafeId == cafeId && b.IsActive && b.DeletedAt == null)
|
||||||
|
.OrderBy(b => b.Id)
|
||||||
|
.Select(b => b.Id)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (branchId is not null)
|
||||||
|
{
|
||||||
|
var tables = BuildDemoTables(cafeId, branchId);
|
||||||
|
_db.Tables.AddRange(tables);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
tablesAdded = tables.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Demo seed complete for cafe {CafeId}: +{Cats} cats, +{Items} items, +{Tables} tables, +{Ing} ingredients, tax={TaxCreated}",
|
||||||
|
cafeId, afterCats - beforeCats, afterItems - beforeItems, tablesAdded, ingredientsAdded, taxCreated);
|
||||||
|
|
||||||
|
return new DemoSeedResult(
|
||||||
|
CategoriesAdded: afterCats - beforeCats,
|
||||||
|
ItemsAdded: afterItems - beforeItems,
|
||||||
|
TablesAdded: tablesAdded,
|
||||||
|
IngredientsAdded: ingredientsAdded,
|
||||||
|
TaxCreated: taxCreated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Ingredient> BuildDemoIngredients(string cafeId) =>
|
||||||
|
[
|
||||||
|
Ingredient(cafeId, "قهوه اسپرسو", "گرم", 2000, 500, 80, 2000),
|
||||||
|
Ingredient(cafeId, "شیر", "میلیلیتر", 10000, 2000, 15, 10000),
|
||||||
|
Ingredient(cafeId, "شکر", "گرم", 5000, 1000, 5, 5000),
|
||||||
|
Ingredient(cafeId, "وانیل", "میلیلیتر", 500, 100, 50, 500),
|
||||||
|
Ingredient(cafeId, "شکلات تلخ", "گرم", 1000, 200, 120, 1000),
|
||||||
|
Ingredient(cafeId, "خامه", "میلیلیتر", 2000, 500, 30, 2000),
|
||||||
|
Ingredient(cafeId, "دارچین", "گرم", 300, 50, 40, 300),
|
||||||
|
Ingredient(cafeId, "چای سیاه", "گرم", 1000, 200, 60, 1000),
|
||||||
|
Ingredient(cafeId, "آب معدنی", "میلیلیتر", 20000, 5000, 3, 20000),
|
||||||
|
Ingredient(cafeId, "نان تست", "عدد", 100, 20, 8000, 100),
|
||||||
|
Ingredient(cafeId, "تخممرغ", "عدد", 60, 12, 6000, 60),
|
||||||
|
Ingredient(cafeId, "کره", "گرم", 500, 100, 80, 500),
|
||||||
|
Ingredient(cafeId, "پنیر", "گرم", 1000, 200, 90, 1000),
|
||||||
|
Ingredient(cafeId, "اسپاتولا یخ", "عدد", 200, 50, 2000, 200),
|
||||||
|
Ingredient(cafeId, "سس کارامل", "میلیلیتر", 1000, 200, 60, 1000),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static Ingredient Ingredient(
|
||||||
|
string cafeId, string name, string unit,
|
||||||
|
decimal qty, decimal reorder, decimal cost, decimal par) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = $"{cafeId}_ing_{Guid.NewGuid():N}"[..36],
|
||||||
|
CafeId = cafeId,
|
||||||
|
Name = name,
|
||||||
|
Unit = unit,
|
||||||
|
QuantityOnHand = qty,
|
||||||
|
ReorderLevel = reorder,
|
||||||
|
UnitCost = cost,
|
||||||
|
ParLevel = par,
|
||||||
|
LowStockWarningPercent = 20m
|
||||||
|
};
|
||||||
|
|
||||||
|
private static List<Table> BuildDemoTables(string cafeId, string branchId)
|
||||||
|
{
|
||||||
|
var tables = new List<Table>();
|
||||||
|
// Floor 1: tables 1-4
|
||||||
|
for (var i = 1; i <= 4; i++)
|
||||||
|
tables.Add(Table(cafeId, branchId, i.ToString(), 4, "طبقه اول", i));
|
||||||
|
// Floor 2: tables 5-8
|
||||||
|
for (var i = 5; i <= 8; i++)
|
||||||
|
tables.Add(Table(cafeId, branchId, i.ToString(), 4, "طبقه دوم", i));
|
||||||
|
// VIP: tables 9-10
|
||||||
|
for (var i = 9; i <= 10; i++)
|
||||||
|
tables.Add(Table(cafeId, branchId, i.ToString(), 6, "VIP", i));
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Table Table(
|
||||||
|
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}"[..36],
|
||||||
|
CafeId = cafeId,
|
||||||
|
BranchId = branchId,
|
||||||
|
Number = number,
|
||||||
|
Capacity = capacity,
|
||||||
|
Floor = floor,
|
||||||
|
SortOrder = sortOrder,
|
||||||
|
QrCode = Guid.NewGuid().ToString("N"),
|
||||||
|
IsActive = true,
|
||||||
|
IsCleaning = false
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -16,10 +16,22 @@ public interface IAuthService
|
|||||||
VerifyOtpRequest request,
|
VerifyOtpRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
|
||||||
|
LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
|
||||||
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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Meezi.API.Models.Auth;
|
using Meezi.API.Models.Auth;
|
||||||
using Meezi.Core.Utilities;
|
using Meezi.Core.Utilities;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace Meezi.API.Validators;
|
namespace Meezi.API.Validators;
|
||||||
|
|
||||||
@@ -51,6 +52,10 @@ public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
|
|||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
.MaximumLength(100)
|
.MaximumLength(100)
|
||||||
.WithMessage("Cafe name must be between 1 and 100 characters.");
|
.WithMessage("Cafe name must be between 1 and 100 characters.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Slug)
|
||||||
|
.Must(s => s == null || SlugHelper.IsValidSlug(s))
|
||||||
|
.WithMessage("Slug must be 2-80 lowercase letters, digits, or hyphens (e.g. my-cafe).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Meezi.API.Models.Cafes;
|
using Meezi.API.Models.Cafes;
|
||||||
|
using Meezi.Core.Utilities;
|
||||||
|
|
||||||
namespace Meezi.API.Validators;
|
namespace Meezi.API.Validators;
|
||||||
|
|
||||||
@@ -8,6 +9,10 @@ public class PatchCafeSettingsRequestValidator : AbstractValidator<PatchCafeSett
|
|||||||
public PatchCafeSettingsRequestValidator()
|
public PatchCafeSettingsRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).MaximumLength(200).When(x => x.Name is not null);
|
RuleFor(x => x.Name).MaximumLength(200).When(x => x.Name is not null);
|
||||||
|
RuleFor(x => x.Slug)
|
||||||
|
.Must(s => s == null || SlugHelper.IsValidSlug(s))
|
||||||
|
.WithMessage("Slug must be 2-80 lowercase letters, digits, or hyphens (e.g. my-cafe).")
|
||||||
|
.When(x => x.Slug is not null);
|
||||||
RuleFor(x => x.Phone).MaximumLength(20).When(x => x.Phone is not null);
|
RuleFor(x => x.Phone).MaximumLength(20).When(x => x.Phone is not null);
|
||||||
RuleFor(x => x.Address).MaximumLength(500).When(x => x.Address is not null);
|
RuleFor(x => x.Address).MaximumLength(500).When(x => x.Address is not null);
|
||||||
RuleFor(x => x.City).MaximumLength(100).When(x => x.City is not null);
|
RuleFor(x => x.City).MaximumLength(100).When(x => x.City is not null);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public class CreateMenuCategoryRequestValidator : AbstractValidator<CreateMenuCa
|
|||||||
public CreateMenuCategoryRequestValidator()
|
public CreateMenuCategoryRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
|
||||||
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||||
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
||||||
RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null);
|
RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null);
|
||||||
@@ -39,7 +39,7 @@ public class CreateMenuItemRequestValidator : AbstractValidator<CreateMenuItemRe
|
|||||||
{
|
{
|
||||||
RuleFor(x => x.CategoryId).NotEmpty();
|
RuleFor(x => x.CategoryId).NotEmpty();
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
|
||||||
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
|
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
|
||||||
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.Admin.API.Models;
|
using Meezi.Admin.API.Models;
|
||||||
using Meezi.Admin.API.Services;
|
using Meezi.Admin.API.Services;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace Meezi.Admin.API.Controllers;
|
namespace Meezi.Admin.API.Controllers;
|
||||||
|
|
||||||
@@ -55,6 +58,39 @@ public class AdminAuthController : ControllerBase
|
|||||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
public async Task<IActionResult> LoginWithPassword(
|
||||||
|
[FromBody] LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
return BadRequest(ValidationError("Username and password are required."));
|
||||||
|
|
||||||
|
var (success, data, code, message) = await _auth.LoginWithPasswordAsync(request, cancellationToken);
|
||||||
|
if (!success)
|
||||||
|
return ErrorResult(code!, message!);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("password")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> ChangePassword(
|
||||||
|
[FromBody] ChangePasswordRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var adminId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||||
|
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (string.IsNullOrEmpty(adminId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var (success, code, message) = await _auth.ChangePasswordAsync(adminId, request, cancellationToken);
|
||||||
|
if (!success)
|
||||||
|
return ErrorResult(code!, message!);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -75,6 +111,9 @@ public class AdminAuthController : ControllerBase
|
|||||||
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
|
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ApiResponse<object> ValidationError(string message) =>
|
||||||
|
new(false, null, new ApiError("VALIDATION_ERROR", message));
|
||||||
|
|
||||||
private IActionResult ErrorResult(string code, string message) =>
|
private IActionResult ErrorResult(string code, string message) =>
|
||||||
code switch
|
code switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ public record VerifyOtpRequest(string Phone, string Code);
|
|||||||
|
|
||||||
public record RefreshTokenRequest(string RefreshToken);
|
public record RefreshTokenRequest(string RefreshToken);
|
||||||
|
|
||||||
|
public record LoginWithPasswordRequest(string Username, string Password);
|
||||||
|
|
||||||
|
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||||
|
|
||||||
public record AuthTokenResponse(
|
public record AuthTokenResponse(
|
||||||
string AccessToken,
|
string AccessToken,
|
||||||
string RefreshToken,
|
string RefreshToken,
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ public interface IAdminAuthService
|
|||||||
VerifyOtpRequest request,
|
VerifyOtpRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
|
||||||
|
LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
|
||||||
|
string adminId,
|
||||||
|
ChangePasswordRequest request,
|
||||||
|
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);
|
||||||
@@ -141,6 +150,49 @@ public class AdminAuthService : IAdminAuthService
|
|||||||
return (true, tokens, null, null);
|
return (true, tokens, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
|
||||||
|
LoginWithPasswordRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var username = request.Username.Trim();
|
||||||
|
var admin = await _db.SystemAdmins
|
||||||
|
.FirstOrDefaultAsync(a => a.Username == username && a.IsActive && a.DeletedAt == null, cancellationToken);
|
||||||
|
|
||||||
|
if (admin is null || string.IsNullOrWhiteSpace(admin.PasswordHash))
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
|
||||||
|
|
||||||
|
if (!PasswordHasher.Verify(request.Password, admin.PasswordHash))
|
||||||
|
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
|
||||||
|
|
||||||
|
var tokens = await IssueTokensAsync(admin, cancellationToken);
|
||||||
|
return (true, tokens, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
|
||||||
|
string adminId,
|
||||||
|
ChangePasswordRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var admin = await _db.SystemAdmins
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == adminId && a.IsActive && a.DeletedAt == null, cancellationToken);
|
||||||
|
if (admin is null)
|
||||||
|
return (false, "NOT_FOUND", "Admin not found.");
|
||||||
|
|
||||||
|
// If a password is already set, require the current one
|
||||||
|
if (!string.IsNullOrWhiteSpace(admin.PasswordHash))
|
||||||
|
{
|
||||||
|
if (!PasswordHasher.Verify(request.CurrentPassword, admin.PasswordHash))
|
||||||
|
return (false, "INVALID_CREDENTIALS", "Current password is incorrect.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
|
||||||
|
return (false, "VALIDATION_ERROR", "New password must be at least 8 characters.");
|
||||||
|
|
||||||
|
admin.PasswordHash = PasswordHasher.Hash(request.NewPassword);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return (true, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||||
Core.Entities.SystemAdmin admin,
|
Core.Entities.SystemAdmin admin,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ public class AdminWebsiteService(AppDbContext db) : IAdminWebsiteService
|
|||||||
var q = db.WebsiteBlogPosts.AsQueryable();
|
var q = db.WebsiteBlogPosts.AsQueryable();
|
||||||
if (published.HasValue) q = q.Where(p => p.IsPublished == published.Value);
|
if (published.HasValue) q = q.Where(p => p.IsPublished == published.Value);
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
var posts = await q.OrderByDescending(p => p.CreatedAt)
|
var posts = await q
|
||||||
|
.Include(p => p.Comments)
|
||||||
|
.OrderByDescending(p => p.CreatedAt)
|
||||||
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
|
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
|
||||||
return new { Posts = posts.Select(MapPost), Total = total, Page = page, Limit = limit };
|
return new { Posts = posts.Select(MapPost), Total = total, Page = page, Limit = limit };
|
||||||
}
|
}
|
||||||
@@ -162,5 +164,6 @@ public class AdminWebsiteService(AppDbContext db) : IAdminWebsiteService
|
|||||||
p.Id, p.Slug, p.TitleFa, p.TitleEn, p.ExcerptFa, p.ExcerptEn,
|
p.Id, p.Slug, p.TitleFa, p.TitleEn, p.ExcerptFa, p.ExcerptEn,
|
||||||
p.ContentFa, p.ContentEn, p.CategoryFa, p.CategoryEn, p.Author,
|
p.ContentFa, p.ContentEn, p.CategoryFa, p.CategoryEn, p.Author,
|
||||||
p.TagsJson, p.CoverImage, p.IsPublished, p.PublishedAt, p.ViewCount, p.CreatedAt,
|
p.TagsJson, p.CoverImage, p.IsPublished, p.PublishedAt, p.ViewCount, p.CreatedAt,
|
||||||
|
CommentCount = p.Comments?.Count ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ public class SendOtpRequestValidator : AbstractValidator<SendOtpRequest>
|
|||||||
{
|
{
|
||||||
public SendOtpRequestValidator()
|
public SendOtpRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Phone).Must(PhoneNormalizer.IsValidIranMobile).WithMessage("Invalid phone number.");
|
RuleFor(x => x.Phone)
|
||||||
|
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
|
||||||
|
.WithMessage("Invalid phone number.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +18,9 @@ public class VerifyOtpRequestValidator : AbstractValidator<VerifyOtpRequest>
|
|||||||
{
|
{
|
||||||
public VerifyOtpRequestValidator()
|
public VerifyOtpRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Phone).Must(PhoneNormalizer.IsValidIranMobile);
|
RuleFor(x => x.Phone)
|
||||||
|
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
|
||||||
|
.WithMessage("Invalid phone number.");
|
||||||
RuleFor(x => x.Code)
|
RuleFor(x => x.Code)
|
||||||
.Must(OtpNormalizer.IsValidSixDigitCode)
|
.Must(OtpNormalizer.IsValidSixDigitCode)
|
||||||
.WithMessage("OTP must be 6 digits.");
|
.WithMessage("OTP must be 6 digits.");
|
||||||
|
|||||||
@@ -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>());
|
||||||
|
}
|
||||||
@@ -54,4 +54,24 @@ public static class PlanLimits
|
|||||||
PlanTier.Enterprise => 100,
|
PlanTier.Enterprise => 100,
|
||||||
_ => 0
|
_ => 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Maximum active menu categories. Free tier is capped at 3; Pro+ is unlimited.</summary>
|
||||||
|
public static int MaxMenuCategories(PlanTier tier) => tier switch
|
||||||
|
{
|
||||||
|
PlanTier.Free => 3,
|
||||||
|
_ => int.MaxValue
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Maximum menu items. Free tier is capped at 30; Pro+ is unlimited.</summary>
|
||||||
|
public static int MaxMenuItems(PlanTier tier) => tier switch
|
||||||
|
{
|
||||||
|
PlanTier.Free => 30,
|
||||||
|
_ => int.MaxValue
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>CRM (customers, loyalty) is only available on Pro and above.</summary>
|
||||||
|
public static bool CanAccessCrm(PlanTier tier) => tier >= PlanTier.Pro;
|
||||||
|
|
||||||
|
/// <summary>Statistics and analytics dashboards are only available on Pro and above.</summary>
|
||||||
|
public static bool CanAccessStatistics(PlanTier tier) => tier >= PlanTier.Pro;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ public class Cafe : BaseEntity
|
|||||||
public string? InstagramHandle { get; set; }
|
public string? InstagramHandle { get; set; }
|
||||||
/// <summary>Cafe website URL, max 300 chars.</summary>
|
/// <summary>Cafe website URL, max 300 chars.</summary>
|
||||||
public string? WebsiteUrl { get; set; }
|
public string? WebsiteUrl { get; set; }
|
||||||
|
/// <summary>WGS-84 latitude (positive = north). Null until owner sets location.</summary>
|
||||||
|
public double? Latitude { get; set; }
|
||||||
|
/// <summary>WGS-84 longitude (positive = east). Null until owner sets location.</summary>
|
||||||
|
public double? Longitude { get; set; }
|
||||||
/// <summary>Default VAT/sales tax % for all branches unless branch override is allowed.</summary>
|
/// <summary>Default VAT/sales tax % for all branches unless branch override is allowed.</summary>
|
||||||
public decimal DefaultTaxRate { get; set; } = 9m;
|
public decimal DefaultTaxRate { get; set; } = 9m;
|
||||||
public bool AllowBranchTaxOverride { get; set; }
|
public bool AllowBranchTaxOverride { get; set; }
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ public class Employee : TenantEntity
|
|||||||
public decimal BaseSalary { get; set; }
|
public decimal BaseSalary { get; set; }
|
||||||
public string? PinCode { get; set; }
|
public string? PinCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional username for password-based dashboard/POS login (set by cafe admin).</summary>
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled for this employee.</summary>
|
||||||
|
public string? PasswordHash { 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 ICollection<Order> Orders { get; set; } = [];
|
public ICollection<Order> Orders { get; set; } = [];
|
||||||
@@ -19,4 +25,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; }
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ public class SystemAdmin : BaseEntity
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string Phone { get; set; } = string.Empty;
|
public string Phone { get; set; } = string.Empty;
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Optional username for password-based login (alternative to OTP).</summary>
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled.</summary>
|
||||||
|
public string? PasswordHash { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Meezi.Core.Utilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PBKDF2/SHA-256 password hashing with no external dependencies.
|
||||||
|
/// Format stored: "{iterations}.{salt_b64}.{hash_b64}"
|
||||||
|
/// </summary>
|
||||||
|
public static class PasswordHasher
|
||||||
|
{
|
||||||
|
private const int SaltSize = 16; // 128-bit salt
|
||||||
|
private const int HashSize = 32; // 256-bit hash
|
||||||
|
private const int Iterations = 100_000; // NIST-recommended minimum
|
||||||
|
private static readonly HashAlgorithmName Algo = HashAlgorithmName.SHA256;
|
||||||
|
|
||||||
|
public static string Hash(string password)
|
||||||
|
{
|
||||||
|
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||||
|
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, Algo, HashSize);
|
||||||
|
return $"{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Verify(string password, string storedHash)
|
||||||
|
{
|
||||||
|
var parts = storedHash.Split('.');
|
||||||
|
if (parts.Length != 3) return false;
|
||||||
|
if (!int.TryParse(parts[0], out var iterations)) return false;
|
||||||
|
|
||||||
|
byte[] salt, expectedHash;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
salt = Convert.FromBase64String(parts[1]);
|
||||||
|
expectedHash = Convert.FromBase64String(parts[2]);
|
||||||
|
}
|
||||||
|
catch (FormatException) { return false; }
|
||||||
|
|
||||||
|
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, Algo, expectedHash.Length);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(actual, expectedHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Meezi.Core.Utilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts Persian/Arabic café names to URL-safe Latin slugs.
|
||||||
|
/// Used for Koja profile URLs (koja.meezi.ir/fa/cafe/{slug}).
|
||||||
|
/// </summary>
|
||||||
|
public static partial class SlugHelper
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<char, string> PersianToLatin = new()
|
||||||
|
{
|
||||||
|
// Alef variants
|
||||||
|
{ 'آ', "a" }, { 'ا', "a" }, { 'أ', "a" }, { 'إ', "a" },
|
||||||
|
// Ba, Pa, Ta, Tha
|
||||||
|
{ 'ب', "b" }, { 'پ', "p" }, { 'ت', "t" }, { 'ث', "s" },
|
||||||
|
// Jim, Che, He, Khe
|
||||||
|
{ 'ج', "j" }, { 'چ', "ch" }, { 'ح', "h" }, { 'خ', "kh" },
|
||||||
|
// Dal, Zal, Re, Ze, Zhe
|
||||||
|
{ 'د', "d" }, { 'ذ', "z" }, { 'ر', "r" }, { 'ز', "z" }, { 'ژ', "zh" },
|
||||||
|
// Sin, Shin, Sad, Zad
|
||||||
|
{ 'س', "s" }, { 'ش', "sh" }, { 'ص', "s" }, { 'ض', "z" },
|
||||||
|
// Ta, Za, Ain, Ghain
|
||||||
|
{ 'ط', "t" }, { 'ظ', "z" }, { 'ع', "a" }, { 'غ', "gh" },
|
||||||
|
// Fa, Ghaf, Kaf (Arabic+Persian), Gaf
|
||||||
|
{ 'ف', "f" }, { 'ق', "gh" }, { 'ک', "k" }, { 'ك', "k" }, { 'گ', "g" },
|
||||||
|
// Lam, Mim, Nun, Vav, He, Ye
|
||||||
|
{ 'ل', "l" }, { 'م', "m" }, { 'ن', "n" }, { 'و', "v" },
|
||||||
|
{ 'ه', "h" }, { 'ی', "i" }, { 'ي', "i" },
|
||||||
|
// Special
|
||||||
|
{ 'ئ', "y" }, { 'ء', "" }, { 'ة', "t" }, { 'ى', "a" }, { 'ؤ', "o" },
|
||||||
|
// Persian digits
|
||||||
|
{ '۰', "0" }, { '۱', "1" }, { '۲', "2" }, { '۳', "3" }, { '۴', "4" },
|
||||||
|
{ '۵', "5" }, { '۶', "6" }, { '۷', "7" }, { '۸', "8" }, { '۹', "9" },
|
||||||
|
// Arabic-Indic digits
|
||||||
|
{ '٠', "0" }, { '١', "1" }, { '٢', "2" }, { '٣', "3" }, { '٤', "4" },
|
||||||
|
{ '٥', "5" }, { '٦', "6" }, { '٧', "7" }, { '٨', "8" }, { '٩', "9" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a café name (Persian or Latin) to a URL-safe lowercase slug.
|
||||||
|
/// Returns an empty string if no valid characters can be extracted.
|
||||||
|
/// </summary>
|
||||||
|
public static string Slugify(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
|
||||||
|
|
||||||
|
var sb = new StringBuilder(input.Length * 2);
|
||||||
|
foreach (var ch in input)
|
||||||
|
{
|
||||||
|
if (PersianToLatin.TryGetValue(ch, out var latin))
|
||||||
|
{
|
||||||
|
sb.Append(latin);
|
||||||
|
}
|
||||||
|
else if (char.IsAsciiLetterOrDigit(ch))
|
||||||
|
{
|
||||||
|
sb.Append(char.ToLowerInvariant(ch));
|
||||||
|
}
|
||||||
|
else if (ch is ' ' or '-' or '_' or '\t')
|
||||||
|
{
|
||||||
|
sb.Append('-');
|
||||||
|
}
|
||||||
|
// else: skip punctuation/unsupported characters
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse consecutive hyphens and trim
|
||||||
|
return MultipleHyphen().Replace(sb.ToString(), "-").Trim('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the slug is a valid Koja URL slug:
|
||||||
|
/// 2–80 lowercase letters, digits, or internal hyphens. Must start and end with a letter/digit.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsValidSlug(string? slug)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(slug)) return false;
|
||||||
|
if (slug.Length < 2 || slug.Length > 80) return false;
|
||||||
|
return ValidSlugPattern().IsMatch(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"-{2,}")]
|
||||||
|
private static partial Regex MultipleHyphen();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*[a-z0-9]$")]
|
||||||
|
private static partial Regex ValidSlugPattern();
|
||||||
|
}
|
||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -6,8 +6,23 @@ namespace Meezi.Infrastructure.Data;
|
|||||||
|
|
||||||
public static class DemoMenuSeeder
|
public static class DemoMenuSeeder
|
||||||
{
|
{
|
||||||
public static async Task EnsureMenuAsync(AppDbContext db, string cafeId, string taxId, ILogger logger)
|
/// <param name="useScopedIds">
|
||||||
|
/// When true, category and item IDs are prefixed with <paramref name="cafeId"/> so
|
||||||
|
/// multiple cafés can each have their own copy of the demo menu without a primary-key
|
||||||
|
/// collision. Pass false only for the legacy demo café (cafe_demo_001) whose IDs are
|
||||||
|
/// already in the database without a café prefix.
|
||||||
|
/// </param>
|
||||||
|
public static async Task EnsureMenuAsync(
|
||||||
|
AppDbContext db, string cafeId, string taxId, ILogger logger,
|
||||||
|
bool useScopedIds = false)
|
||||||
{
|
{
|
||||||
|
// When useScopedIds=true every row gets a deterministic ID that is unique per café:
|
||||||
|
// category → "{cafeId}_{catalogId}"
|
||||||
|
// item → "{cafeId}_{catalogId}"
|
||||||
|
// The catalog item's CategoryId is remapped through the same function.
|
||||||
|
string Scoped(string catalogId) =>
|
||||||
|
useScopedIds ? $"{cafeId}_{catalogId}" : catalogId;
|
||||||
|
|
||||||
if (!await db.Taxes.AnyAsync(t => t.Id == taxId && t.CafeId == cafeId))
|
if (!await db.Taxes.AnyAsync(t => t.Id == taxId && t.CafeId == cafeId))
|
||||||
{
|
{
|
||||||
db.Taxes.Add(new Tax
|
db.Taxes.Add(new Tax
|
||||||
@@ -29,7 +44,8 @@ public static class DemoMenuSeeder
|
|||||||
var categoriesAdded = 0;
|
var categoriesAdded = 0;
|
||||||
foreach (var cat in DemoMenuCatalog.Categories)
|
foreach (var cat in DemoMenuCatalog.Categories)
|
||||||
{
|
{
|
||||||
if (existingCategoryIds.TryGetValue(cat.Id, out var row))
|
var catId = Scoped(cat.Id);
|
||||||
|
if (existingCategoryIds.TryGetValue(catId, out var row))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(row.Icon) && !string.IsNullOrWhiteSpace(cat.Icon))
|
if (string.IsNullOrWhiteSpace(row.Icon) && !string.IsNullOrWhiteSpace(cat.Icon))
|
||||||
row.Icon = cat.Icon;
|
row.Icon = cat.Icon;
|
||||||
@@ -46,7 +62,7 @@ public static class DemoMenuSeeder
|
|||||||
|
|
||||||
db.MenuCategories.Add(new MenuCategory
|
db.MenuCategories.Add(new MenuCategory
|
||||||
{
|
{
|
||||||
Id = cat.Id,
|
Id = catId,
|
||||||
CafeId = cafeId,
|
CafeId = cafeId,
|
||||||
Name = cat.Name,
|
Name = cat.Name,
|
||||||
NameEn = cat.NameEn,
|
NameEn = cat.NameEn,
|
||||||
@@ -69,14 +85,15 @@ public static class DemoMenuSeeder
|
|||||||
var itemsAdded = 0;
|
var itemsAdded = 0;
|
||||||
foreach (var item in DemoMenuCatalog.Items)
|
foreach (var item in DemoMenuCatalog.Items)
|
||||||
{
|
{
|
||||||
if (existingItemIds.Contains(item.Id))
|
var itemId = Scoped(item.Id);
|
||||||
|
if (existingItemIds.Contains(itemId))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
db.MenuItems.Add(new MenuItem
|
db.MenuItems.Add(new MenuItem
|
||||||
{
|
{
|
||||||
Id = item.Id,
|
Id = itemId,
|
||||||
CafeId = cafeId,
|
CafeId = cafeId,
|
||||||
CategoryId = item.CategoryId,
|
CategoryId = Scoped(item.CategoryId), // FK must point at scoped category ID
|
||||||
Name = item.Name,
|
Name = item.Name,
|
||||||
NameEn = item.NameEn,
|
NameEn = item.NameEn,
|
||||||
NameAr = item.NameAr,
|
NameAr = item.NameAr,
|
||||||
|
|||||||
+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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3299
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPasswordLogin : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "SystemAdmins",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Username",
|
||||||
|
table: "SystemAdmins",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "Employees",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Username",
|
||||||
|
table: "Employees",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "SystemAdmins");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Username",
|
||||||
|
table: "SystemAdmins");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "Employees");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Username",
|
||||||
|
table: "Employees");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260601120000_AddCafeLocation")]
|
||||||
|
public partial class AddCafeLocation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Latitude",
|
||||||
|
table: "Cafes",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<double>(
|
||||||
|
name: "Longitude",
|
||||||
|
table: "Cafes",
|
||||||
|
type: "double precision",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Latitude",
|
||||||
|
table: "Cafes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Longitude",
|
||||||
|
table: "Cafes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -261,9 +327,15 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("IsVerified")
|
b.Property<bool>("IsVerified")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<double?>("Latitude")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<string>("LogoUrl")
|
b.Property<string>("LogoUrl")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<double?>("Longitude")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@@ -863,6 +935,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Property<string>("NationalId")
|
b.Property<string>("NationalId")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -873,6 +948,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Property<int>("Role")
|
b.Property<int>("Role")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("BranchId");
|
b.HasIndex("BranchId");
|
||||||
@@ -884,6 +962,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 +1434,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");
|
||||||
|
|
||||||
@@ -2005,11 +2131,17 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Phone")
|
b.HasIndex("Phone")
|
||||||
@@ -2424,6 +2556,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 +2706,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 +3172,8 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.Navigation("Staff");
|
b.Navigation("Staff");
|
||||||
|
|
||||||
|
b.Navigation("StaffRoles");
|
||||||
|
|
||||||
b.Navigation("Tables");
|
b.Navigation("Tables");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3061,6 +3223,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");
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ using Meezi.Core.Constants;
|
|||||||
using Meezi.Core.Entities;
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Platform;
|
using Meezi.Core.Platform;
|
||||||
|
using Meezi.Core.Utilities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -17,18 +19,25 @@ public static class PlatformDataSeeder
|
|||||||
public static async Task SeedAsync(IServiceProvider services)
|
public static async Task SeedAsync(IServiceProvider services)
|
||||||
{
|
{
|
||||||
var env = services.GetRequiredService<IHostEnvironment>();
|
var env = services.GetRequiredService<IHostEnvironment>();
|
||||||
if (!env.IsDevelopment())
|
|
||||||
return;
|
|
||||||
|
|
||||||
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
|
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
|
||||||
await using var scope = services.CreateAsyncScope();
|
await using var scope = services.CreateAsyncScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||||
|
|
||||||
await EnsureCatalogUpgradesAsync(db, logger);
|
// Production-safe: ensure the platform owner's system-admin account exists
|
||||||
|
// on every boot (ALL environments) so the admin panel is reachable on a
|
||||||
|
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
|
||||||
|
await EnsureOwnerAdminAsync(db, config, logger);
|
||||||
|
|
||||||
if (!env.IsDevelopment())
|
if (!env.IsDevelopment())
|
||||||
|
{
|
||||||
|
// Production: also ensure integration settings (Kavenegar enabled/template,
|
||||||
|
// etc.) exist so the admin Integrations page is populated. Idempotent.
|
||||||
|
await EnsureIntegrationSettingsAsync(db, logger);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnsureCatalogUpgradesAsync(db, logger);
|
||||||
await SeedSystemAdminAsync(db, logger);
|
await SeedSystemAdminAsync(db, logger);
|
||||||
await SeedPlansAsync(db, logger);
|
await SeedPlansAsync(db, logger);
|
||||||
await SeedFeaturesAsync(db, logger);
|
await SeedFeaturesAsync(db, logger);
|
||||||
@@ -36,6 +45,77 @@ public static class PlatformDataSeeder
|
|||||||
await EnsureIntegrationSettingsAsync(db, logger);
|
await EnsureIntegrationSettingsAsync(db, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures the platform owner's system-admin account exists in EVERY environment
|
||||||
|
/// (including production), so the admin panel is reachable on a fresh deploy.
|
||||||
|
/// The phone is configurable via "Seed:SystemAdminPhone" (env Seed__SystemAdminPhone)
|
||||||
|
/// and defaults to the platform owner's number. Idempotent — never duplicates.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task EnsureOwnerAdminAsync(AppDbContext db, IConfiguration config, ILogger logger)
|
||||||
|
{
|
||||||
|
const string DefaultOwnerPhone = "09190345606";
|
||||||
|
const string DefaultAdminUsername = "admin";
|
||||||
|
|
||||||
|
var configuredPhone = config["Seed:SystemAdminPhone"];
|
||||||
|
var phone = PhoneNormalizer.Normalize(
|
||||||
|
string.IsNullOrWhiteSpace(configuredPhone) ? DefaultOwnerPhone : configuredPhone);
|
||||||
|
|
||||||
|
if (!PhoneNormalizer.IsValidIranMobile(phone))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Owner system-admin seed skipped — invalid phone '{Phone}'", phone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var configuredUsername = config["Seed:SystemAdminUsername"];
|
||||||
|
var username = string.IsNullOrWhiteSpace(configuredUsername) ? DefaultAdminUsername : configuredUsername.Trim().ToLowerInvariant();
|
||||||
|
var defaultPassword = config["Seed:SystemAdminPassword"]; // optional — only set if provided
|
||||||
|
|
||||||
|
var existing = await db.SystemAdmins.FirstOrDefaultAsync(a => a.Phone == phone);
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
var admin = new SystemAdmin
|
||||||
|
{
|
||||||
|
Id = "sysadmin_owner",
|
||||||
|
Name = "مدیر سامانه",
|
||||||
|
Phone = phone,
|
||||||
|
IsActive = true,
|
||||||
|
Username = username,
|
||||||
|
PasswordHash = string.IsNullOrWhiteSpace(defaultPassword) ? null : PasswordHasher.Hash(defaultPassword)
|
||||||
|
};
|
||||||
|
db.SystemAdmins.Add(admin);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Seeded owner system admin with phone {Phone}, username '{Username}'", phone, username);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Owner system admin already seeded by another instance");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch existing admin: fill in missing username / password without overwriting set values
|
||||||
|
var patched = false;
|
||||||
|
if (string.IsNullOrWhiteSpace(existing.Username))
|
||||||
|
{
|
||||||
|
existing.Username = username;
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(existing.PasswordHash) && !string.IsNullOrWhiteSpace(defaultPassword))
|
||||||
|
{
|
||||||
|
existing.PasswordHash = PasswordHasher.Hash(defaultPassword);
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
if (patched)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Patched owner system admin credentials (username/password)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary>
|
/// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary>
|
||||||
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
|
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
|
||||||
{
|
{
|
||||||
@@ -45,8 +125,46 @@ public static class PlatformDataSeeder
|
|||||||
await EnsureCatalogUpgradesAsync(db, logger);
|
await EnsureCatalogUpgradesAsync(db, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures every café has at least one active branch. Idempotent.
|
||||||
|
/// Creates a default branch named after the café for any café that has none.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task EnsureDefaultBranchesAsync(AppDbContext db, ILogger logger)
|
||||||
|
{
|
||||||
|
// Load café IDs that have zero branches in one query
|
||||||
|
var cafeIdsWithBranches = await db.Branches
|
||||||
|
.Where(b => b.DeletedAt == null)
|
||||||
|
.Select(b => b.CafeId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var cafesWithoutBranch = await db.Cafes
|
||||||
|
.Where(c => c.DeletedAt == null && !cafeIdsWithBranches.Contains(c.Id))
|
||||||
|
.Select(c => new { c.Id, c.Name })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (cafesWithoutBranch.Count == 0) return;
|
||||||
|
|
||||||
|
foreach (var cafe in cafesWithoutBranch)
|
||||||
|
{
|
||||||
|
db.Branches.Add(new Branch
|
||||||
|
{
|
||||||
|
CafeId = cafe.Id,
|
||||||
|
Name = cafe.Name,
|
||||||
|
IsActive = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
logger.LogInformation("Created default branch for {Count} café(s) that had none", cafesWithoutBranch.Count);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task EnsureCatalogUpgradesAsync(AppDbContext db, ILogger logger)
|
private static async Task EnsureCatalogUpgradesAsync(AppDbContext db, ILogger logger)
|
||||||
{
|
{
|
||||||
|
// Ensure every café has at least one branch. Cafés registered before the
|
||||||
|
// auto-branch feature was added are patched on the first boot after upgrade.
|
||||||
|
await EnsureDefaultBranchesAsync(db, logger);
|
||||||
|
|
||||||
var featureAdds = new[]
|
var featureAdds = new[]
|
||||||
{
|
{
|
||||||
("menu_3d", "منوی سهبعدی", "3D menu", "growth"),
|
("menu_3d", "منوی سهبعدی", "3D menu", "growth"),
|
||||||
@@ -126,7 +244,7 @@ public static class PlatformDataSeeder
|
|||||||
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
||||||
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
||||||
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
||||||
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"),
|
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
|
||||||
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
|
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
|
||||||
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
||||||
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
||||||
@@ -296,7 +414,7 @@ public static class PlatformDataSeeder
|
|||||||
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
|
||||||
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
|
||||||
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوهنگار"),
|
||||||
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"),
|
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
|
||||||
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
|
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
|
||||||
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
|
||||||
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
|
||||||
|
|||||||
@@ -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,35 +9,31 @@ 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>
|
||||||
public class KavenegarSmsService : ISmsService
|
public class KavenegarSmsService : ISmsService
|
||||||
{
|
{
|
||||||
// ── DB config keys ────────────────────────────────────────────────────────
|
// ── DB config keys ────────────────────────────────────────────────────────
|
||||||
private const string DbKeyApiKey = "integrations.kavenegar.apiKey";
|
private const string DbKeyApiKey = "integrations.kavenegar.apiKey";
|
||||||
private const string DbKeyEnabled = "integrations.kavenegar.enabled";
|
private const string DbKeyEnabled = "integrations.kavenegar.enabled";
|
||||||
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>
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,10 @@ public class SupportTicketService : ISupportTicketService
|
|||||||
string cafeId,
|
string cafeId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await QueryTickets()
|
// NOTE: The Where MUST be applied on the EF entity set BEFORE the Select projection.
|
||||||
.Where(t => t.CafeId == cafeId)
|
// Applying Where() after Select() onto a DTO record causes an EF translation error
|
||||||
|
// because EF can't translate "new SupportTicketDto(...).CafeId == x".
|
||||||
|
return await QueryTickets(cafeId)
|
||||||
.OrderByDescending(t => t.UpdatedAt)
|
.OrderByDescending(t => t.UpdatedAt)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -119,11 +121,10 @@ public class SupportTicketService : ISupportTicketService
|
|||||||
SupportTicketStatus? status,
|
SupportTicketStatus? status,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var q = QueryTickets();
|
// status filter is applied on the entity before projection — safe for EF translation.
|
||||||
if (status.HasValue)
|
return await QueryTickets(cafeId: null, status: status)
|
||||||
q = q.Where(t => t.Status == status.Value);
|
.OrderByDescending(t => t.UpdatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
return await q.OrderByDescending(t => t.UpdatedAt).ToListAsync(cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SupportTicketDetailDto?> GetAdminAsync(
|
public async Task<SupportTicketDetailDto?> GetAdminAsync(
|
||||||
@@ -185,22 +186,36 @@ public class SupportTicketService : ISupportTicketService
|
|||||||
return await GetAdminAsync(ticketId, cancellationToken);
|
return await GetAdminAsync(ticketId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IQueryable<SupportTicketDto> QueryTickets() =>
|
/// <summary>
|
||||||
_db.SupportTickets
|
/// Builds an EF-translatable query for support ticket list rows.
|
||||||
.AsNoTracking()
|
/// Filters are applied on the entity BEFORE the Select projection to avoid EF translation errors.
|
||||||
.Select(t => new SupportTicketDto(
|
/// </summary>
|
||||||
t.Id,
|
private IQueryable<SupportTicketDto> QueryTickets(
|
||||||
t.CafeId,
|
string? cafeId = null,
|
||||||
t.Cafe != null ? t.Cafe.Name : "",
|
SupportTicketStatus? status = null)
|
||||||
t.Subject,
|
{
|
||||||
t.Status,
|
var q = _db.SupportTickets.AsNoTracking().AsQueryable();
|
||||||
t.Priority,
|
|
||||||
t.CreatedByEmployeeId,
|
// Apply entity-level filters BEFORE Select so EF can translate them.
|
||||||
t.CreatedByEmployee != null ? t.CreatedByEmployee.Name : null,
|
if (cafeId is not null)
|
||||||
t.AssignedAdminId,
|
q = q.Where(t => t.CafeId == cafeId);
|
||||||
t.CreatedAt,
|
if (status.HasValue)
|
||||||
t.UpdatedAt,
|
q = q.Where(t => t.Status == status.Value);
|
||||||
t.Messages.Count));
|
|
||||||
|
return q.Select(t => new SupportTicketDto(
|
||||||
|
t.Id,
|
||||||
|
t.CafeId,
|
||||||
|
t.Cafe != null ? t.Cafe.Name : "",
|
||||||
|
t.Subject,
|
||||||
|
t.Status,
|
||||||
|
t.Priority,
|
||||||
|
t.CreatedByEmployeeId,
|
||||||
|
t.CreatedByEmployee != null ? t.CreatedByEmployee.Name : null,
|
||||||
|
t.AssignedAdminId,
|
||||||
|
t.CreatedAt,
|
||||||
|
t.UpdatedAt,
|
||||||
|
t.Messages.Count));
|
||||||
|
}
|
||||||
|
|
||||||
private static SupportTicketDto MapTicket(SupportTicket t) =>
|
private static SupportTicketDto MapTicket(SupportTicket t) =>
|
||||||
new(
|
new(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Discover;
|
using Meezi.Core.Discover;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
namespace Meezi.API.Tests;
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ public class DiscoverFilterTests
|
|||||||
noise: "quiet",
|
noise: "quiet",
|
||||||
priceTier: "mid",
|
priceTier: "mid",
|
||||||
size: null,
|
size: null,
|
||||||
requireProfile: true);
|
requireProfile: true,
|
||||||
|
openNow: false);
|
||||||
Assert.Equal("تهران", f.City);
|
Assert.Equal("تهران", f.City);
|
||||||
Assert.Equal(4, f.MinRating);
|
Assert.Equal(4, f.MinRating);
|
||||||
Assert.Contains("modern", f.Themes!);
|
Assert.Contains("modern", f.Themes!);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Meezi.API.Security;
|
||||||
|
|
||||||
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
|
/// <summary>Test double that allows every action and has no captcha configured.</summary>
|
||||||
|
internal sealed class NoOpAbuseProtectionService : IAbuseProtectionService
|
||||||
|
{
|
||||||
|
public bool IsCaptchaConfigured => false;
|
||||||
|
public string? CaptchaSiteKey => null;
|
||||||
|
|
||||||
|
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckAuthOtpByIpAsync(
|
||||||
|
string clientIp, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
|
||||||
|
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckGuestOrderAsync(
|
||||||
|
string cafeId, string clientIp, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
|
||||||
|
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckPublicWriteByIpAsync(
|
||||||
|
string clientIp, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
|
||||||
|
public Task<(bool Ok, string? ErrorCode, string? Message)> VerifyCaptchaAsync(
|
||||||
|
string? captchaToken, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
}
|
||||||
@@ -16,9 +16,17 @@ internal sealed class NoOpInventoryService : IInventoryService
|
|||||||
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
|
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IngredientDto?>(null);
|
Task.FromResult<IngredientDto?>(null);
|
||||||
|
|
||||||
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, CancellationToken ct = default) =>
|
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IngredientDto?>(null);
|
Task.FromResult<IngredientDto?>(null);
|
||||||
|
|
||||||
|
public Task<InventoryPurchasesSummaryDto> GetPurchasesSummaryAsync(
|
||||||
|
string cafeId,
|
||||||
|
string branchId,
|
||||||
|
DateOnly from,
|
||||||
|
DateOnly to,
|
||||||
|
CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(new InventoryPurchasesSummaryDto(0, 0, []));
|
||||||
|
|
||||||
public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) =>
|
public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) =>
|
||||||
Task.FromResult<MenuItemRecipeDto?>(null);
|
Task.FromResult<MenuItemRecipeDto?>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Entities;
|
||||||
|
|
||||||
namespace Meezi.API.Tests;
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
@@ -10,4 +11,11 @@ internal sealed class NoOpLoyaltyService : ILoyaltyService
|
|||||||
decimal paidAmount,
|
decimal paidAmount,
|
||||||
CancellationToken ct = default) =>
|
CancellationToken ct = default) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<(bool Success, LoyaltyRedeemResult? Data, string? ErrorCode)> RedeemOnOrderAsync(
|
||||||
|
string cafeId,
|
||||||
|
Order order,
|
||||||
|
int pointsRequested,
|
||||||
|
CancellationToken ct = default) =>
|
||||||
|
Task.FromResult<(bool, LoyaltyRedeemResult?, string?)>((false, null, null));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Meezi.API.Services;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
|
/// <summary>Test double that stores nothing and returns no URL.</summary>
|
||||||
|
internal sealed class NoOpMediaStorageService : IMediaStorageService
|
||||||
|
{
|
||||||
|
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveMenuModel3dFromBytesAsync(string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
@@ -11,4 +11,7 @@ internal sealed class NoOpOrderNotificationService : IOrderNotificationService
|
|||||||
|
|
||||||
public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) =>
|
public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task NotifyCallWaiterAsync(string cafeId, string tableId, string tableNumber, CancellationToken ct = default) =>
|
||||||
|
Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class PrintingTests
|
|||||||
218_000m,
|
218_000m,
|
||||||
0m,
|
0m,
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
|
1,
|
||||||
[
|
[
|
||||||
new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null),
|
new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null),
|
||||||
new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true)
|
new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using Meezi.API.Models.Menu;
|
using Meezi.API.Models.Menu;
|
||||||
using Meezi.API.Models.Orders;
|
using Meezi.API.Models.Orders;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
|
using Meezi.API.Security;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Entities;
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -114,7 +116,11 @@ public class QrMenuTests
|
|||||||
var tables = new TableService(db, config, kds, identity);
|
var tables = new TableService(db, config, kds, identity);
|
||||||
var shifts = new ShiftService(db);
|
var shifts = new ShiftService(db);
|
||||||
var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
||||||
var publicSvc = new PublicService(db, orders, new ReviewService(db), kds, branchMenu, identity);
|
var abuse = new NoOpAbuseProtectionService();
|
||||||
|
var http = new HttpContextAccessor();
|
||||||
|
var media = new NoOpMediaStorageService();
|
||||||
|
var reviews = new ReviewService(db, abuse, http, media);
|
||||||
|
var publicSvc = new PublicService(db, orders, reviews, kds, branchMenu, identity, abuse, http);
|
||||||
|
|
||||||
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
|
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1057,6 +1057,7 @@
|
|||||||
"fieldCategoryEn": "الفئة بالإنجليزية",
|
"fieldCategoryEn": "الفئة بالإنجليزية",
|
||||||
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
|
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
|
||||||
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
|
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
|
||||||
|
"fieldPublished": "منشور",
|
||||||
"commentsTitle": "إدارة التعليقات",
|
"commentsTitle": "إدارة التعليقات",
|
||||||
"noComments": "لا توجد تعليقات",
|
"noComments": "لا توجد تعليقات",
|
||||||
"approved": "موافق عليه",
|
"approved": "موافق عليه",
|
||||||
@@ -1093,7 +1094,14 @@
|
|||||||
"otp": "رمز التحقق",
|
"otp": "رمز التحقق",
|
||||||
"login": "دخول",
|
"login": "دخول",
|
||||||
"error": "فشل تسجيل الدخول",
|
"error": "فشل تسجيل الدخول",
|
||||||
"devHint": "في التطوير يُطبع الرمز في سجل Admin API."
|
"devHint": "في التطوير يُطبع الرمز في سجل Admin API.",
|
||||||
|
"tabOtp": "رمز مؤقت",
|
||||||
|
"tabPassword": "كلمة المرور",
|
||||||
|
"username": "اسم المستخدم",
|
||||||
|
"usernamePlaceholder": "اسم المستخدم",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"passwordPlaceholder": "كلمة المرور",
|
||||||
|
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "نظرة عامة",
|
"title": "نظرة عامة",
|
||||||
|
|||||||
@@ -1058,6 +1058,7 @@
|
|||||||
"fieldCategoryEn": "Category (English)",
|
"fieldCategoryEn": "Category (English)",
|
||||||
"fieldContentFa": "Content (Persian, Markdown)",
|
"fieldContentFa": "Content (Persian, Markdown)",
|
||||||
"fieldContentEn": "Content (English, Markdown)",
|
"fieldContentEn": "Content (English, Markdown)",
|
||||||
|
"fieldPublished": "Published",
|
||||||
"commentsTitle": "Comment management",
|
"commentsTitle": "Comment management",
|
||||||
"noComments": "No comments found",
|
"noComments": "No comments found",
|
||||||
"approved": "Approved",
|
"approved": "Approved",
|
||||||
@@ -1086,7 +1087,14 @@
|
|||||||
"otp": "Verification code",
|
"otp": "Verification code",
|
||||||
"login": "Sign in",
|
"login": "Sign in",
|
||||||
"error": "Login failed",
|
"error": "Login failed",
|
||||||
"devHint": "In development the OTP is logged by Admin API (DEV admin OTP)."
|
"devHint": "In development the OTP is logged by Admin API (DEV admin OTP).",
|
||||||
|
"tabOtp": "One-time code",
|
||||||
|
"tabPassword": "Password",
|
||||||
|
"username": "Username",
|
||||||
|
"usernamePlaceholder": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordPlaceholder": "Password",
|
||||||
|
"invalidCredentials": "Incorrect username or password."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Platform overview",
|
"title": "Platform overview",
|
||||||
|
|||||||
@@ -1058,6 +1058,7 @@
|
|||||||
"fieldCategoryEn": "دستهبندی انگلیسی",
|
"fieldCategoryEn": "دستهبندی انگلیسی",
|
||||||
"fieldContentFa": "محتوا فارسی (Markdown)",
|
"fieldContentFa": "محتوا فارسی (Markdown)",
|
||||||
"fieldContentEn": "محتوا انگلیسی (Markdown)",
|
"fieldContentEn": "محتوا انگلیسی (Markdown)",
|
||||||
|
"fieldPublished": "وضعیت انتشار",
|
||||||
"commentsTitle": "مدیریت نظرات",
|
"commentsTitle": "مدیریت نظرات",
|
||||||
"noComments": "نظری یافت نشد",
|
"noComments": "نظری یافت نشد",
|
||||||
"approved": "تأیید شده",
|
"approved": "تأیید شده",
|
||||||
@@ -1086,7 +1087,14 @@
|
|||||||
"otp": "کد تأیید",
|
"otp": "کد تأیید",
|
||||||
"login": "ورود",
|
"login": "ورود",
|
||||||
"error": "خطا در ورود",
|
"error": "خطا در ورود",
|
||||||
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ میشود (DEV admin OTP)."
|
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ میشود (DEV admin OTP).",
|
||||||
|
"tabOtp": "کد یکبارمصرف",
|
||||||
|
"tabPassword": "رمز عبور",
|
||||||
|
"username": "نام کاربری",
|
||||||
|
"usernamePlaceholder": "نام کاربری",
|
||||||
|
"password": "رمز عبور",
|
||||||
|
"passwordPlaceholder": "رمز عبور",
|
||||||
|
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "خلاصه سامانه",
|
"title": "خلاصه سامانه",
|
||||||
|
|||||||
@@ -13,14 +13,25 @@ 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";
|
||||||
|
|
||||||
|
type LoginTab = "otp" | "password";
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const t = useTranslations("admin.auth");
|
const t = useTranslations("admin.auth");
|
||||||
const tAuth = useTranslations("auth");
|
const tAuth = useTranslations("auth");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setAuth = useAdminAuthStore((s) => s.setAuth);
|
const setAuth = useAdminAuthStore((s) => s.setAuth);
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<LoginTab>("otp");
|
||||||
|
|
||||||
|
// OTP state
|
||||||
const [phone, setPhone] = useState("09120000001");
|
const [phone, setPhone] = useState("09120000001");
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone");
|
||||||
|
|
||||||
|
// Password state
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -34,6 +45,8 @@ export default function AdminLoginPage() {
|
|||||||
case "INVALID_OTP":
|
case "INVALID_OTP":
|
||||||
case "VALIDATION_ERROR":
|
case "VALIDATION_ERROR":
|
||||||
return tAuth("invalidOtp");
|
return tAuth("invalidOtp");
|
||||||
|
case "INVALID_TOKEN":
|
||||||
|
return t("invalidCredentials");
|
||||||
default:
|
default:
|
||||||
return err.message;
|
return err.message;
|
||||||
}
|
}
|
||||||
@@ -46,7 +59,7 @@ export default function AdminLoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await adminPost("/api/admin/auth/send-otp", { phone });
|
await adminPost("/api/admin/auth/send-otp", { phone });
|
||||||
setStep("otp");
|
setOtpStep("otp");
|
||||||
setCode("");
|
setCode("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(authErrorMessage(e));
|
setError(authErrorMessage(e));
|
||||||
@@ -55,7 +68,7 @@ export default function AdminLoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const verify = async () => {
|
const verifyOtp = async () => {
|
||||||
const normalized = normalizeOtpInput(code);
|
const normalized = normalizeOtpInput(code);
|
||||||
if (normalized.length !== 6) {
|
if (normalized.length !== 6) {
|
||||||
setError(tAuth("invalidOtp"));
|
setError(tAuth("invalidOtp"));
|
||||||
@@ -77,6 +90,34 @@ export default function AdminLoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loginWithPassword = async () => {
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
setError(t("invalidCredentials"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await adminPost<AuthTokenResponse>("/api/admin/auth/login", {
|
||||||
|
username: username.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
setAuth(data);
|
||||||
|
router.push("/admin");
|
||||||
|
} catch (e) {
|
||||||
|
setError(authErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchTab = (next: LoginTab) => {
|
||||||
|
setTab(next);
|
||||||
|
setError(null);
|
||||||
|
setOtpStep("phone");
|
||||||
|
setCode("");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl">
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
@@ -87,8 +128,36 @@ export default function AdminLoginPage() {
|
|||||||
<p className="text-center text-xs text-muted-foreground">{t("devHint")}</p>
|
<p className="text-center text-xs text-muted-foreground">{t("devHint")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{step === "phone" ? (
|
{/* Tab switcher */}
|
||||||
|
<div className="flex border-b px-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
|
||||||
|
tab === "otp"
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => switchTab("otp")}
|
||||||
|
>
|
||||||
|
{t("tabOtp")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
|
||||||
|
tab === "password"
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => switchTab("password")}
|
||||||
|
>
|
||||||
|
{t("tabPassword")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4 pt-4">
|
||||||
|
{/* ───── OTP tab ───── */}
|
||||||
|
{tab === "otp" && otpStep === "phone" && (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -111,12 +180,14 @@ export default function AdminLoginPage() {
|
|||||||
{loading ? "..." : t("sendOtp")}
|
{loading ? "..." : t("sendOtp")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{tab === "otp" && otpStep === "otp" && (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!loading) void verify();
|
if (!loading) void verifyOtp();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LabeledField label={t("otp")} htmlFor="admin-login-otp">
|
<LabeledField label={t("otp")} htmlFor="admin-login-otp">
|
||||||
@@ -142,7 +213,7 @@ export default function AdminLoginPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStep("phone");
|
setOtpStep("phone");
|
||||||
setCode("");
|
setCode("");
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
@@ -151,6 +222,46 @@ export default function AdminLoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───── Password tab ───── */}
|
||||||
|
{tab === "password" && (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!loading) void loginWithPassword();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LabeledField label={t("username")} htmlFor="admin-login-username">
|
||||||
|
<Input
|
||||||
|
id="admin-login-username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder={t("usernamePlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-start"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<LabeledField label={t("password")} htmlFor="admin-login-password">
|
||||||
|
<Input
|
||||||
|
id="admin-login-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t("passwordPlaceholder")}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-start"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</LabeledField>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading || !username.trim() || !password}>
|
||||||
|
{loading ? "..." : t("login")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{error ? <p className="text-center text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-center text-sm text-destructive">{error}</p> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Link } from "@/i18n/routing";
|
import { Link } from "@/i18n/routing";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
adminDelete,
|
adminDelete,
|
||||||
adminGet,
|
adminGet,
|
||||||
@@ -37,6 +38,57 @@ import {
|
|||||||
type TicketStatus,
|
type TicketStatus,
|
||||||
} from "@/components/support/ticket-status-badge";
|
} from "@/components/support/ticket-status-badge";
|
||||||
|
|
||||||
|
// iOS-style toggle switch used throughout this file
|
||||||
|
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
|
||||||
|
checked ? "translate-x-5" : "translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styled single-select indicator (replaces raw <input type="radio">).
|
||||||
|
function RadioDot({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
selected ? "border-[#0F6E56]" : "border-muted-foreground/40 hover:border-muted-foreground/70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected ? <span className="h-2.5 w-2.5 rounded-full bg-[#0F6E56]" /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminDashboardScreen() {
|
export function AdminDashboardScreen() {
|
||||||
const t = useTranslations("admin.dashboard");
|
const t = useTranslations("admin.dashboard");
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
@@ -78,6 +130,50 @@ function StatCard({ label, value }: { label: string; value: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlanCard({ plan, onSave }: { plan: AdminPlan; onSave: (p: AdminPlan) => void }) {
|
||||||
|
const t = useTranslations("admin.plans");
|
||||||
|
const [price, setPrice] = useState(plan.monthlyPriceToman);
|
||||||
|
const [maxOrders, setMaxOrders] = useState(plan.limits.maxOrdersPerDay);
|
||||||
|
|
||||||
|
// Sync server values if they change (e.g. after successful save + refetch)
|
||||||
|
useEffect(() => { setPrice(plan.monthlyPriceToman); }, [plan.monthlyPriceToman]);
|
||||||
|
useEffect(() => { setMaxOrders(plan.limits.maxOrdersPerDay); }, [plan.limits.maxOrdersPerDay]);
|
||||||
|
|
||||||
|
const flush = () =>
|
||||||
|
onSave({ ...plan, monthlyPriceToman: price, limits: { ...plan.limits, maxOrdersPerDay: maxOrders } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-xl border border-border/80">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">{plan.tier}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("monthlyPrice")}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="mt-1"
|
||||||
|
value={price}
|
||||||
|
onChange={(e) => setPrice(Number(e.target.value))}
|
||||||
|
onBlur={flush}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("maxOrders")}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="mt-1"
|
||||||
|
value={maxOrders}
|
||||||
|
onChange={(e) => setMaxOrders(Number(e.target.value))}
|
||||||
|
onBlur={flush}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminPlansScreen() {
|
export function AdminPlansScreen() {
|
||||||
const t = useTranslations("admin.plans");
|
const t = useTranslations("admin.plans");
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -108,38 +204,7 @@ export function AdminPlansScreen() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||||
{plans.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<Card key={plan.tier} className="rounded-xl border border-border/80">
|
<PlanCard key={plan.tier} plan={plan} onSave={(p) => save.mutate(p)} />
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
|
|
||||||
<p className="text-xs text-muted-foreground">{plan.tier}</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<label className="text-sm">
|
|
||||||
{t("monthlyPrice")}
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="mt-1"
|
|
||||||
value={plan.monthlyPriceToman}
|
|
||||||
onChange={(e) => {
|
|
||||||
plan.monthlyPriceToman = Number(e.target.value);
|
|
||||||
}}
|
|
||||||
onBlur={() => save.mutate(plan)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="text-sm">
|
|
||||||
{t("maxOrders")}
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="mt-1"
|
|
||||||
defaultValue={plan.limits.maxOrdersPerDay}
|
|
||||||
onBlur={(e) => {
|
|
||||||
plan.limits.maxOrdersPerDay = Number(e.target.value);
|
|
||||||
save.mutate(plan);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -166,17 +231,34 @@ export function AdminSettingsScreen() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{settings.map((s) => (
|
{settings.map((s) => {
|
||||||
<Card key={s.id} className="rounded-xl border border-border/80 p-4">
|
const isBool = s.value === "true" || s.value === "false";
|
||||||
<p className="text-xs text-muted-foreground">{s.key}</p>
|
return (
|
||||||
<p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p>
|
<Card key={s.id} className="rounded-xl border border-border/80 p-4">
|
||||||
<Input
|
<div className={isBool ? "flex items-center justify-between gap-3" : undefined}>
|
||||||
className="mt-2"
|
<div className="min-w-0">
|
||||||
defaultValue={s.value}
|
<p className="text-sm font-medium text-foreground">{s.key}</p>
|
||||||
onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })}
|
{s.descriptionFa ? (
|
||||||
/>
|
<p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p>
|
||||||
</Card>
|
) : null}
|
||||||
))}
|
</div>
|
||||||
|
{isBool ? (
|
||||||
|
<Toggle
|
||||||
|
checked={s.value === "true"}
|
||||||
|
onChange={(v) => save.mutate({ key: s.key, value: String(v) })}
|
||||||
|
disabled={save.isPending}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="mt-2"
|
||||||
|
defaultValue={s.value}
|
||||||
|
onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -577,34 +659,30 @@ export function AdminIntegrationsScreen() {
|
|||||||
<Card key={g.id} className="rounded-xl border border-border/80 p-4 space-y-3">
|
<Card key={g.id} className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<RadioDot
|
||||||
type="radio"
|
selected={activeGateway === g.id}
|
||||||
name="activeGateway"
|
onSelect={() => setActiveGateway(g.id)}
|
||||||
checked={activeGateway === g.id}
|
|
||||||
onChange={() => setActiveGateway(g.id)}
|
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{g.displayNameFa}</span>
|
<span className="font-medium">{g.displayNameFa}</span>
|
||||||
{activeGateway === g.id ? (
|
{activeGateway === g.id ? (
|
||||||
<Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge>
|
<Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={g.isEnabled}
|
checked={g.isEnabled}
|
||||||
onChange={(e) => updateGateway(g.id, { isEnabled: e.target.checked })}
|
onChange={(v) => updateGateway(g.id, { isEnabled: v })}
|
||||||
/>
|
/>
|
||||||
{t("enabled")}
|
<span>{t("enabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={g.sandbox}
|
checked={g.sandbox}
|
||||||
onChange={(e) => updateGateway(g.id, { sandbox: e.target.checked })}
|
onChange={(v) => updateGateway(g.id, { sandbox: v })}
|
||||||
/>
|
/>
|
||||||
{t("sandbox")}
|
<span>{t("sandbox")}</span>
|
||||||
</label>
|
</div>
|
||||||
{g.id === "zarinpal" ? (
|
{g.id === "zarinpal" ? (
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
{t("merchantId")}
|
{t("merchantId")}
|
||||||
@@ -779,14 +857,13 @@ export function AdminIntegrationsScreen() {
|
|||||||
{t("kavenegarTitle")}
|
{t("kavenegarTitle")}
|
||||||
</p>
|
</p>
|
||||||
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={kavenegar.isEnabled}
|
checked={kavenegar.isEnabled}
|
||||||
onChange={(e) => setKavenegar((k) => ({ ...k, isEnabled: e.target.checked }))}
|
onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
|
||||||
/>
|
/>
|
||||||
{t("enabled")}
|
<span>{t("enabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
{t("apiKey")}
|
{t("apiKey")}
|
||||||
<Input
|
<Input
|
||||||
@@ -815,14 +892,13 @@ export function AdminIntegrationsScreen() {
|
|||||||
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||||
<p className="text-sm font-medium">{t("openAiTitle")}</p>
|
<p className="text-sm font-medium">{t("openAiTitle")}</p>
|
||||||
<p className="text-xs text-muted-foreground">{t("openAiHint")}</p>
|
<p className="text-xs text-muted-foreground">{t("openAiHint")}</p>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={openAi.isEnabled}
|
checked={openAi.isEnabled}
|
||||||
onChange={(e) => setOpenAi((o) => ({ ...o, isEnabled: e.target.checked }))}
|
onChange={(v) => setOpenAi((o) => ({ ...o, isEnabled: v }))}
|
||||||
/>
|
/>
|
||||||
{t("enabled")}
|
<span>{t("enabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
{t("openAiApiKey")}
|
{t("openAiApiKey")}
|
||||||
<Input
|
<Input
|
||||||
@@ -836,35 +912,37 @@ export function AdminIntegrationsScreen() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
{t("openAiModel")}
|
{t("openAiModel")}
|
||||||
<Input
|
<select
|
||||||
className="mt-1"
|
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
value={openAi.model}
|
value={openAi.model}
|
||||||
onChange={(e) => setOpenAi((o) => ({ ...o, model: e.target.value }))}
|
onChange={(e) => setOpenAi((o) => ({ ...o, model: e.target.value }))}
|
||||||
/>
|
>
|
||||||
|
<option value="gpt-4o-mini">gpt-4o-mini (fast, cheap)</option>
|
||||||
|
<option value="gpt-4o">gpt-4o (best quality)</option>
|
||||||
|
<option value="gpt-4-turbo">gpt-4-turbo</option>
|
||||||
|
<option value="gpt-4">gpt-4</option>
|
||||||
|
<option value="gpt-3.5-turbo">gpt-3.5-turbo (legacy)</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={openAi.coffeeAdvisorEnabled}
|
checked={openAi.coffeeAdvisorEnabled}
|
||||||
onChange={(e) =>
|
onChange={(v) => setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: v }))}
|
||||||
setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: e.target.checked }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{t("coffeeAdvisorEnabled")}
|
<span>{t("coffeeAdvisorEnabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||||
<p className="text-sm font-medium">{t("meshyTitle")}</p>
|
<p className="text-sm font-medium">{t("meshyTitle")}</p>
|
||||||
<p className="text-xs text-muted-foreground">{t("meshyHint")}</p>
|
<p className="text-xs text-muted-foreground">{t("meshyHint")}</p>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={meshy.isEnabled}
|
checked={meshy.isEnabled}
|
||||||
onChange={(e) => setMeshy((m) => ({ ...m, isEnabled: e.target.checked }))}
|
onChange={(v) => setMeshy((m) => ({ ...m, isEnabled: v }))}
|
||||||
/>
|
/>
|
||||||
{t("enabled")}
|
<span>{t("enabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
{t("meshyApiKey")}
|
{t("meshyApiKey")}
|
||||||
<Input
|
<Input
|
||||||
@@ -876,14 +954,13 @@ export function AdminIntegrationsScreen() {
|
|||||||
onChange={(e) => setMeshy((m) => ({ ...m, apiKey: e.target.value }))}
|
onChange={(e) => setMeshy((m) => ({ ...m, apiKey: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Toggle
|
||||||
type="checkbox"
|
|
||||||
checked={meshy.menu3dEnabled}
|
checked={meshy.menu3dEnabled}
|
||||||
onChange={(e) => setMeshy((m) => ({ ...m, menu3dEnabled: e.target.checked }))}
|
onChange={(v) => setMeshy((m) => ({ ...m, menu3dEnabled: v }))}
|
||||||
/>
|
/>
|
||||||
{t("menu3dEnabled")}
|
<span>{t("menu3dEnabled")}</span>
|
||||||
</label>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const user = useAdminAuthStore((s) => s.user);
|
const user = useAdminAuthStore((s) => s.user);
|
||||||
|
const hasHydrated = useAdminAuthStore((s) => s._hasHydrated);
|
||||||
const clearAuth = useAdminAuthStore((s) => s.clearAuth);
|
const clearAuth = useAdminAuthStore((s) => s.clearAuth);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.accessToken) router.replace("/admin/login");
|
if (hasHydrated && !user?.accessToken) router.replace("/admin/login");
|
||||||
}, [user, router]);
|
}, [user, hasHydrated, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-muted/30" dir="rtl">
|
<div className="flex min-h-screen bg-muted/30" dir="rtl">
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { adminDelete, adminGet, adminPatch, adminPost, adminPut } from "@/lib/api/admin-client";
|
import { adminDelete, adminGet, adminPatch, adminPost, adminPut } from "@/lib/api/admin-client";
|
||||||
import type {
|
import type {
|
||||||
AdminBlogPost,
|
AdminBlogPost,
|
||||||
@@ -15,6 +16,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Link } from "@/i18n/routing";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
@@ -66,13 +69,13 @@ export function AdminBlogListScreen() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-lg font-medium">{t("blogTitle")}</h1>
|
<h1 className="text-lg font-medium">{t("blogTitle")}</h1>
|
||||||
<a
|
<Link
|
||||||
href="website/blog/new"
|
href="/admin/website/blog/new"
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
{t("newPost")}
|
{t("newPost")}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -113,7 +116,7 @@ export function AdminBlogListScreen() {
|
|||||||
asChild
|
asChild
|
||||||
className="h-8 px-2 text-xs"
|
className="h-8 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<a href={`website/blog/${post.id}`}>{t("edit")}</a>
|
<Link href={`/admin/website/blog/${post.id}`}>{t("edit")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -147,6 +150,74 @@ export function AdminBlogListScreen() {
|
|||||||
|
|
||||||
// ── Blog Post Editor ─────────────────────────────────────────────────────────
|
// ── Blog Post Editor ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// iOS-style toggle (mirrors the one in admin-screens.tsx).
|
||||||
|
function BlogToggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
|
||||||
|
checked ? "translate-x-5" : "translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level so it keeps a stable component identity across renders.
|
||||||
|
// (Previously defined inside the editor, which remounted every input on each
|
||||||
|
// keystroke and dropped focus after a single character.)
|
||||||
|
function BlogField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
multiline,
|
||||||
|
dir,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
multiline?: boolean;
|
||||||
|
dir: "rtl" | "ltr";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
|
||||||
|
{multiline ? (
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
dir={dir}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
dir={dir}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface PostEditorProps {
|
interface PostEditorProps {
|
||||||
postId?: string; // undefined = new post
|
postId?: string; // undefined = new post
|
||||||
}
|
}
|
||||||
@@ -154,6 +225,7 @@ interface PostEditorProps {
|
|||||||
export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
||||||
const t = useTranslations("admin.website");
|
const t = useTranslations("admin.website");
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
const isNew = !postId;
|
const isNew = !postId;
|
||||||
|
|
||||||
const { data: post } = useQuery({
|
const { data: post } = useQuery({
|
||||||
@@ -162,7 +234,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
|||||||
enabled: !isNew,
|
enabled: !isNew,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const emptyForm = {
|
||||||
slug: "",
|
slug: "",
|
||||||
titleFa: "",
|
titleFa: "",
|
||||||
titleEn: "",
|
titleEn: "",
|
||||||
@@ -173,85 +245,63 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
|||||||
author: "تیم میزی",
|
author: "تیم میزی",
|
||||||
categoryFa: "",
|
categoryFa: "",
|
||||||
categoryEn: "",
|
categoryEn: "",
|
||||||
});
|
isPublished: false,
|
||||||
|
};
|
||||||
|
|
||||||
// Sync fetched data into form once loaded
|
const [form, setForm] = useState(emptyForm);
|
||||||
const initialised = !isNew && post;
|
const [formReady, setFormReady] = useState(isNew);
|
||||||
const displayForm = initialised
|
|
||||||
? {
|
// Populate form from server data the first time it arrives
|
||||||
slug: post!.slug,
|
useEffect(() => {
|
||||||
titleFa: post!.titleFa,
|
if (post && !formReady) {
|
||||||
titleEn: post!.titleEn,
|
setForm({
|
||||||
excerptFa: post!.excerptFa,
|
slug: post.slug,
|
||||||
excerptEn: post!.excerptEn,
|
titleFa: post.titleFa,
|
||||||
contentFa: post!.contentFa,
|
titleEn: post.titleEn,
|
||||||
contentEn: post!.contentEn,
|
excerptFa: post.excerptFa,
|
||||||
author: post!.author,
|
excerptEn: post.excerptEn,
|
||||||
categoryFa: post!.categoryFa,
|
contentFa: post.contentFa,
|
||||||
categoryEn: post!.categoryEn,
|
contentEn: post.contentEn,
|
||||||
}
|
author: post.author,
|
||||||
: form;
|
categoryFa: post.categoryFa,
|
||||||
|
categoryEn: post.categoryEn,
|
||||||
|
isPublished: post.isPublished,
|
||||||
|
});
|
||||||
|
setFormReady(true);
|
||||||
|
}
|
||||||
|
}, [post, formReady]);
|
||||||
|
|
||||||
|
const setField = (key: keyof typeof form) => (v: string) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: v }));
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: (data: typeof form) =>
|
mutationFn: () =>
|
||||||
isNew
|
isNew
|
||||||
? adminPost("/api/admin/website/posts", data)
|
? adminPost<{ id: string }>("/api/admin/website/posts", form)
|
||||||
: adminPut(`/api/admin/website/posts/${postId}`, data),
|
: adminPut<{ id: string }>(`/api/admin/website/posts/${postId}`, form),
|
||||||
onSuccess: () => {
|
onSuccess: (result) => {
|
||||||
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
|
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
|
||||||
notify.success(t("saved"));
|
notify.success(t("saved"));
|
||||||
|
// After creating a new post, go to its edit page so the user can
|
||||||
|
// continue editing and won't accidentally hit Save again (which would
|
||||||
|
// fail on the unique slug constraint).
|
||||||
|
if (isNew) router.push(`/admin/website/blog/${result.id}`);
|
||||||
},
|
},
|
||||||
onError: () => notify.error(t("errorGeneric")),
|
onError: () => notify.error(t("errorGeneric")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const Field = ({
|
if (!isNew && !formReady) {
|
||||||
label,
|
return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
|
||||||
value,
|
}
|
||||||
onChange,
|
|
||||||
multiline,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
multiline?: boolean;
|
|
||||||
}) => (
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
|
|
||||||
{multiline ? (
|
|
||||||
<textarea
|
|
||||||
rows={8}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
|
|
||||||
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="h-9 text-sm"
|
|
||||||
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const current = initialised ? post! : form;
|
|
||||||
const setField = (key: keyof typeof form) => (v: string) => {
|
|
||||||
if (initialised) {
|
|
||||||
// We'd need local state override — keep it simple for demo
|
|
||||||
}
|
|
||||||
setForm((f) => ({ ...f, [key]: v }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<a href="." className="flex items-center gap-1.5">
|
<Link href="/admin/website/blog" className="flex items-center gap-1.5">
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
{t("backToBlog")}
|
{t("backToBlog")}
|
||||||
</a>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-lg font-medium">
|
<h1 className="text-lg font-medium">
|
||||||
{isNew ? t("newPost") : t("editPost")}
|
{isNew ? t("newPost") : t("editPost")}
|
||||||
@@ -261,80 +311,35 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
|||||||
<Card className="rounded-xl border-border/80">
|
<Card className="rounded-xl border-border/80">
|
||||||
<CardContent className="space-y-4 pt-5">
|
<CardContent className="space-y-4 pt-5">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field
|
<BlogField label={t("fieldSlug")} value={form.slug} onChange={setField("slug")} dir="ltr" />
|
||||||
label={t("fieldSlug")}
|
<BlogField label={t("fieldAuthor")} value={form.author} onChange={setField("author")} dir="rtl" />
|
||||||
value={isNew ? form.slug : (post?.slug ?? "")}
|
|
||||||
onChange={setField("slug")}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={t("fieldAuthor")}
|
|
||||||
value={isNew ? form.author : (post?.author ?? "")}
|
|
||||||
onChange={setField("author")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field
|
<BlogField label={t("fieldTitleFa")} value={form.titleFa} onChange={setField("titleFa")} dir="rtl" />
|
||||||
label={t("fieldTitleFa")}
|
<BlogField label={t("fieldTitleEn")} value={form.titleEn} onChange={setField("titleEn")} dir="ltr" />
|
||||||
value={isNew ? form.titleFa : (post?.titleFa ?? "")}
|
|
||||||
onChange={setField("titleFa")}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={t("fieldTitleEn")}
|
|
||||||
value={isNew ? form.titleEn : (post?.titleEn ?? "")}
|
|
||||||
onChange={setField("titleEn")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field
|
<BlogField label={t("fieldExcerptFa")} value={form.excerptFa} onChange={setField("excerptFa")} dir="rtl" />
|
||||||
label={t("fieldExcerptFa")}
|
<BlogField label={t("fieldExcerptEn")} value={form.excerptEn} onChange={setField("excerptEn")} dir="ltr" />
|
||||||
value={isNew ? form.excerptFa : (post?.excerptFa ?? "")}
|
|
||||||
onChange={setField("excerptFa")}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={t("fieldExcerptEn")}
|
|
||||||
value={isNew ? form.excerptEn : (post?.excerptEn ?? "")}
|
|
||||||
onChange={setField("excerptEn")}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field
|
<BlogField label={t("fieldCategoryFa")} value={form.categoryFa} onChange={setField("categoryFa")} dir="rtl" />
|
||||||
label={t("fieldCategoryFa")}
|
<BlogField label={t("fieldCategoryEn")} value={form.categoryEn} onChange={setField("categoryEn")} dir="ltr" />
|
||||||
value={isNew ? form.categoryFa : (post?.categoryFa ?? "")}
|
</div>
|
||||||
onChange={setField("categoryFa")}
|
<BlogField label={t("fieldContentFa")} value={form.contentFa} onChange={setField("contentFa")} dir="rtl" multiline />
|
||||||
/>
|
<BlogField label={t("fieldContentEn")} value={form.contentEn} onChange={setField("contentEn")} dir="ltr" multiline />
|
||||||
<Field
|
|
||||||
label={t("fieldCategoryEn")}
|
<div className="flex items-center justify-between rounded-lg border border-border/80 px-3 py-2">
|
||||||
value={isNew ? form.categoryEn : (post?.categoryEn ?? "")}
|
<span className="text-sm font-medium">{t("fieldPublished")}</span>
|
||||||
onChange={setField("categoryEn")}
|
<BlogToggle
|
||||||
|
checked={form.isPublished}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, isPublished: v }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Field
|
|
||||||
label={t("fieldContentFa")}
|
|
||||||
value={isNew ? form.contentFa : (post?.contentFa ?? "")}
|
|
||||||
onChange={setField("contentFa")}
|
|
||||||
multiline
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={t("fieldContentEn")}
|
|
||||||
value={isNew ? form.contentEn : (post?.contentEn ?? "")}
|
|
||||||
onChange={setField("contentEn")}
|
|
||||||
multiline
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => saveMut.mutate(isNew ? form : {
|
onClick={() => saveMut.mutate()}
|
||||||
slug: post?.slug ?? form.slug,
|
|
||||||
titleFa: post?.titleFa ?? form.titleFa,
|
|
||||||
titleEn: post?.titleEn ?? form.titleEn,
|
|
||||||
excerptFa: post?.excerptFa ?? form.excerptFa,
|
|
||||||
excerptEn: post?.excerptEn ?? form.excerptEn,
|
|
||||||
contentFa: post?.contentFa ?? form.contentFa,
|
|
||||||
contentEn: post?.contentEn ?? form.contentEn,
|
|
||||||
author: post?.author ?? form.author,
|
|
||||||
categoryFa: post?.categoryFa ?? form.categoryFa,
|
|
||||||
categoryEn: post?.categoryEn ?? form.categoryEn,
|
|
||||||
})}
|
|
||||||
disabled={saveMut.isPending}
|
disabled={saveMut.isPending}
|
||||||
className="min-w-[100px]"
|
className="min-w-[100px]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ import type { AuthTokenResponse } from "@/lib/api/types";
|
|||||||
|
|
||||||
interface AdminAuthState {
|
interface AdminAuthState {
|
||||||
user: AuthTokenResponse | null;
|
user: AuthTokenResponse | null;
|
||||||
|
/** True once Zustand has finished rehydrating from localStorage. */
|
||||||
|
_hasHydrated: boolean;
|
||||||
setAuth: (user: AuthTokenResponse) => void;
|
setAuth: (user: AuthTokenResponse) => void;
|
||||||
clearAuth: () => void;
|
clearAuth: () => void;
|
||||||
isAuthenticated: () => boolean;
|
isAuthenticated: () => boolean;
|
||||||
|
_setHasHydrated: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAdminAuthStore = create<AdminAuthState>()(
|
export const useAdminAuthStore = create<AdminAuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
|
_hasHydrated: false,
|
||||||
|
_setHasHydrated: (v) => set({ _hasHydrated: v }),
|
||||||
setAuth: (user) => {
|
setAuth: (user) => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem("meezi_admin_access_token", user.accessToken);
|
localStorage.setItem("meezi_admin_access_token", user.accessToken);
|
||||||
@@ -29,6 +34,11 @@ export const useAdminAuthStore = create<AdminAuthState>()(
|
|||||||
},
|
},
|
||||||
isAuthenticated: () => !!get().user?.accessToken,
|
isAuthenticated: () => !!get().user?.accessToken,
|
||||||
}),
|
}),
|
||||||
{ name: "meezi_admin_auth" }
|
{
|
||||||
|
name: "meezi_admin_auth",
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
state?._setHasHydrated(true);
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -45,7 +45,17 @@
|
|||||||
"chooseCafe": "اختر المقهى",
|
"chooseCafe": "اختر المقهى",
|
||||||
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
|
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
|
||||||
"createNewCafe": "إنشاء مقهى جديد",
|
"createNewCafe": "إنشاء مقهى جديد",
|
||||||
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟"
|
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟",
|
||||||
|
"tabOtp": "رمز مؤقت",
|
||||||
|
"tabPassword": "كلمة المرور",
|
||||||
|
"username": "اسم المستخدم",
|
||||||
|
"usernamePlaceholder": "اسم المستخدم",
|
||||||
|
"password": "كلمة المرور",
|
||||||
|
"passwordPlaceholder": "كلمة المرور",
|
||||||
|
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة.",
|
||||||
|
"kojaSlug": "عنوان الملف الشخصي في كوجا",
|
||||||
|
"kojaSlugHint": "يجد الزوار مقهاكم على هذا العنوان",
|
||||||
|
"kojaSlugPlaceholder": "مثال: my-cafe"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"owner": "المالك",
|
"owner": "المالك",
|
||||||
@@ -56,8 +66,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 +196,8 @@
|
|||||||
"cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.",
|
"cancelOrderConfirm": "غادر العميل دون دفع؟ سيُلغى الطلب ويُحرَّر الطاولة.",
|
||||||
"cancelOrderSuccess": "تم إلغاء الطلب",
|
"cancelOrderSuccess": "تم إلغاء الطلب",
|
||||||
"cancelOrderError": "تعذّر إلغاء الطلب",
|
"cancelOrderError": "تعذّر إلغاء الطلب",
|
||||||
|
"cancelReasonPlaceholder": "سبب الإلغاء (اختياري)",
|
||||||
|
"cancelOrderHasPayments": "استرجع المدفوعات المسجّلة أولاً ثم ألغِ الطلب",
|
||||||
"itemsCount": "صنف",
|
"itemsCount": "صنف",
|
||||||
"applyCoupon": "تطبيق القسيمة",
|
"applyCoupon": "تطبيق القسيمة",
|
||||||
"couponPlaceholder": "رمز القسيمة",
|
"couponPlaceholder": "رمز القسيمة",
|
||||||
@@ -358,7 +395,9 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"attendance": "الحضور",
|
"attendance": "الحضور",
|
||||||
"leave": "الإجازة",
|
"leave": "الإجازة",
|
||||||
"payroll": "الرواتب"
|
"payroll": "الرواتب",
|
||||||
|
"access": "صلاحيات الفروع",
|
||||||
|
"credentials": "بيانات الدخول"
|
||||||
},
|
},
|
||||||
"myAttendance": "حضوري",
|
"myAttendance": "حضوري",
|
||||||
"clockIn": "تسجيل دخول",
|
"clockIn": "تسجيل دخول",
|
||||||
@@ -368,7 +407,22 @@
|
|||||||
"paid": "مدفوع",
|
"paid": "مدفوع",
|
||||||
"markPaid": "تسجيل الدفع",
|
"markPaid": "تسجيل الدفع",
|
||||||
"employeeCount": "الموظفون",
|
"employeeCount": "الموظفون",
|
||||||
"monthYear": "شهر الرواتب"
|
"monthYear": "شهر الرواتب",
|
||||||
|
"credentials": {
|
||||||
|
"title": "بيانات دخول الموظفين",
|
||||||
|
"subtitle": "حدد اسم مستخدم وكلمة مرور لكل موظف حتى يتمكن من تسجيل الدخول دون رمز OTP.",
|
||||||
|
"selectEmployee": "اختر موظفاً أولاً",
|
||||||
|
"username": "اسم المستخدم",
|
||||||
|
"usernamePlaceholder": "مثال: ali_barista",
|
||||||
|
"password": "كلمة المرور (8 أحرف على الأقل)",
|
||||||
|
"passwordPlaceholder": "كلمة مرور جديدة",
|
||||||
|
"set": "حفظ بيانات الدخول",
|
||||||
|
"remove": "حذف بيانات الدخول",
|
||||||
|
"removeConfirm": "هل أنت متأكد؟ لن يتمكن الموظف من تسجيل الدخول بكلمة مرور بعد الآن.",
|
||||||
|
"saved": "تم حفظ بيانات الدخول.",
|
||||||
|
"removed": "تم حذف بيانات الدخول.",
|
||||||
|
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"reviews": {
|
"reviews": {
|
||||||
"title": "تقييمات العملاء",
|
"title": "تقييمات العملاء",
|
||||||
@@ -945,7 +999,29 @@
|
|||||||
"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": "جارٍ التحويل إلى البوابة...",
|
||||||
|
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "الإعدادات",
|
"title": "الإعدادات",
|
||||||
@@ -1074,7 +1150,13 @@
|
|||||||
"uploadLogo": "رفع الشعار",
|
"uploadLogo": "رفع الشعار",
|
||||||
"uploadCover": "رفع الغلاف",
|
"uploadCover": "رفع الغلاف",
|
||||||
"saved": "تم حفظ الملف.",
|
"saved": "تم حفظ الملف.",
|
||||||
"reloginHint": "تم تحديث الخطة؛ سجّل الخروج والدخول إن لزم."
|
"reloginHint": "تم تحديث الخطة؛ سجّل الخروج والدخول إن لزم.",
|
||||||
|
"slug": "عنوان ملف كوجا",
|
||||||
|
"slugHint": "صفحة مقهاكم على كوجا — أحرف صغيرة وأرقام وشرطات فقط",
|
||||||
|
"slugPlaceholder": "my-cafe",
|
||||||
|
"slugTaken": "هذا العنوان مأخوذ. الرجاء اختيار عنوان آخر.",
|
||||||
|
"slugInvalid": "عنوان غير صالح. استخدم الأحرف الصغيرة والأرقام والشرطات فقط.",
|
||||||
|
"kojaUrl": "رابط كوجا"
|
||||||
},
|
},
|
||||||
"taraz": "تاراز (الضرائب)",
|
"taraz": "تاراز (الضرائب)",
|
||||||
"tarazHint": "إرسال فواتير الأمس إلى تاراز (وضع تجريبي).",
|
"tarazHint": "إرسال فواتير الأمس إلى تاراز (وضع تجريبي).",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user