Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73a5e5183b | |||
| 1daa6d452c | |||
| 24fbbcb01c | |||
| a967e5d211 | |||
| 82d1cf8e9e | |||
| 837805b6b8 | |||
| d4d7b7e679 | |||
| 32a7cf5b25 | |||
| d407f0b3e9 | |||
| 72ab09189c | |||
| 456a446850 | |||
| 4523c8861f | |||
| a855cf1d80 | |||
| 76d4434581 | |||
| 9765491f6f | |||
| 00649d0248 | |||
| 615d5348de | |||
| 74f46a4781 | |||
| c47922414a | |||
| 2a4cf1d20b | |||
| d811b7d6d5 | |||
| e0c786fcd1 | |||
| bafbfbcadf | |||
| 206cd7d3c3 | |||
| 7b77bb4722 | |||
| 1db8a8f08c | |||
| 82145b0d21 | |||
| 59486cdf24 | |||
| f02f78a97c | |||
| cc0933c514 | |||
| 7c35984096 | |||
| bf0ca68fa6 | |||
| 6778c32028 | |||
| 75a0a1c834 | |||
| 8a8eaf37e0 | |||
| 9a27858125 | |||
| 5078af2dd7 | |||
| 4123654077 | |||
| 55e0c9499d | |||
| c8ea364ca2 | |||
| af1794925d | |||
| 2652736d31 | |||
| 1d79dde5e1 | |||
| 45dab8b253 | |||
| e46d833371 | |||
| dcdb0d5747 | |||
| 9b2f15151d | |||
| 7d06f149d3 | |||
| 2487f9e30f | |||
| 8f738f6469 | |||
| 7f52b2823f | |||
| c5d5a4006a | |||
| 4cb640814a | |||
| 4c98c2cce1 | |||
| db0c3a4a02 | |||
| f1756b491e | |||
| 97a9481627 | |||
| eb165db182 | |||
| 3b468b48d9 | |||
| f4583f5169 | |||
| 132f0921e0 | |||
| bb0be19dac | |||
| 15def7ff1c | |||
| 60e2ac1355 | |||
| a37d93f6cd | |||
| 7122df57b2 | |||
| 72f95aa0db | |||
| bab3453e41 | |||
| 24da1e0522 | |||
| 2203ecbdaf | |||
| 1aaab6c593 | |||
| 09bba5f8cd | |||
| 3b8dcf3af6 | |||
| 087563bce7 | |||
| e839db7331 | |||
| a83edf7667 | |||
| 75d5bbc84a | |||
| 7519f474f3 | |||
| 35494d8b32 | |||
| 4c7783884c | |||
| 8ce0b3e3e8 |
@@ -6,6 +6,20 @@
|
||||
"runtimeExecutable": "dotnet",
|
||||
"runtimeArgs": ["run", "--project", "F:/Projects/DrSousan/DrSousan.Api", "--urls", "http://localhost:5000"],
|
||||
"port": 5000
|
||||
},
|
||||
{
|
||||
"name": "meezi-website",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["node_modules/next/dist/bin/next", "dev", "-p", "3013"],
|
||||
"cwd": "web/website",
|
||||
"port": 3013
|
||||
},
|
||||
{
|
||||
"name": "meezi-dashboard",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["node_modules/next/dist/bin/next", "dev", "-p", "3015"],
|
||||
"cwd": "web/dashboard",
|
||||
"port": 3015
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Certificate files must never be line-ending converted (CRLF would corrupt
|
||||
# trust-store parsing on Linux CI runners / Docker builds).
|
||||
*.crt -text
|
||||
*.pem -text
|
||||
*.cer -text
|
||||
@@ -80,10 +80,30 @@ jobs:
|
||||
</configuration>
|
||||
EOF
|
||||
|
||||
- name: Verify mirror TLS chain
|
||||
# The mirror's fullchain.pem now serves leaf → YR2 → ISRG Root YR
|
||||
# (cross-signed by ISRG Root X1, which IS in every stock trust store),
|
||||
# so no custom CA is needed. This step only sanity-checks the chain and
|
||||
# fails early with a clear message if the server cert regresses again.
|
||||
# POSIX sh only — the Gitea act runner v0.6.1 ignores shell: overrides.
|
||||
run: |
|
||||
set -eu
|
||||
echo | openssl s_client -connect mirror.soroushasadi.com:443 \
|
||||
-servername mirror.soroushasadi.com 2>/dev/null \
|
||||
| tee /tmp/sclient.txt | grep "Verify return code" || true
|
||||
if ! grep -q "Verify return code: 0 (ok)" /tmp/sclient.txt; then
|
||||
echo "❌ mirror.soroushasadi.com TLS chain is broken again."
|
||||
echo " Fix the cert ON THE SERVER (/etc/ssl/soroushasadi/fullchain.pem"
|
||||
echo " must include the full chain up to a publicly-trusted root),"
|
||||
echo " then: docker exec mirror-nginx nginx -s reload"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore src/Meezi.API/Meezi.API.csproj --configfile /tmp/nuget.ci.config
|
||||
env:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
||||
NUGET_CERT_REVOCATION_MODE: offline
|
||||
|
||||
- name: Build
|
||||
run: dotnet build src/Meezi.API/Meezi.API.csproj --no-restore -c Release
|
||||
@@ -128,10 +148,23 @@ jobs:
|
||||
</configuration>
|
||||
EOF
|
||||
|
||||
- name: Verify mirror TLS chain
|
||||
# Same sanity check as api-build — see that job for full comments.
|
||||
run: |
|
||||
set -eu
|
||||
echo | openssl s_client -connect mirror.soroushasadi.com:443 \
|
||||
-servername mirror.soroushasadi.com 2>/dev/null \
|
||||
| tee /tmp/sclient.txt | grep "Verify return code" || true
|
||||
if ! grep -q "Verify return code: 0 (ok)" /tmp/sclient.txt; then
|
||||
echo "❌ mirror.soroushasadi.com TLS chain is broken again — fix the server cert."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore src/Meezi.Admin.API/Meezi.Admin.API.csproj --configfile /tmp/nuget.ci.config
|
||||
env:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
||||
NUGET_CERT_REVOCATION_MODE: offline
|
||||
|
||||
- name: Build
|
||||
run: dotnet build src/Meezi.Admin.API/Meezi.Admin.API.csproj --no-restore -c Release
|
||||
@@ -413,6 +446,11 @@ jobs:
|
||||
-f docker-compose.admin.yml \
|
||||
up -d --no-deps admin-web
|
||||
|
||||
- name: Start nightly DB backup
|
||||
# Sidecar that pg_dumps meezi-db nightly into ./backups (14-day retention).
|
||||
# --no-deps so it doesn't try to (re)start postgres which isn't compose-managed.
|
||||
run: docker compose up -d --no-deps backup
|
||||
|
||||
- name: Show all running containers
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
# Domains needed in DNS (all → same server IP):
|
||||
# meezi.ir, app.meezi.ir, api.meezi.ir,
|
||||
# koja.meezi.ir, admin.meezi.ir, admin-api.meezi.ir
|
||||
# status.meezi.ir (only if the monitoring stack is running — see docs/monitoring.md)
|
||||
|
||||
{
|
||||
email {$ACME_EMAIL}
|
||||
@@ -41,3 +42,10 @@ admin.{$DOMAIN} {
|
||||
admin-api.{$DOMAIN} {
|
||||
reverse_proxy admin-api:8080
|
||||
}
|
||||
|
||||
# ── Uptime monitoring (Uptime Kuma) ──────────────────────────────────────────
|
||||
# Only resolves if the monitoring stack is up (docker-compose.monitoring.yml).
|
||||
# Caddy ignores upstreams that don't exist until the container is running.
|
||||
status.{$DOMAIN} {
|
||||
reverse_proxy uptime-kuma:3001
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
|
||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Production}"
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
|
||||
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: meezi
|
||||
|
||||
# Self-hosted uptime monitoring for Meezi — Uptime Kuma.
|
||||
#
|
||||
# One-time stand-up (does NOT need redeploying with every app deploy):
|
||||
# docker compose -f docker-compose.monitoring.yml up -d
|
||||
#
|
||||
# Then open https://status.meezi.ir (or http://SERVER:3201) and configure the
|
||||
# monitors + alert channel as described in docs/monitoring.md.
|
||||
#
|
||||
# Config + history persist in the uptime_kuma_data volume.
|
||||
|
||||
services:
|
||||
uptime-kuma:
|
||||
image: ${UPTIME_KUMA_IMAGE:-mirror.soroushasadi.com/louislam/uptime-kuma:1}
|
||||
container_name: meezi-uptime-kuma
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- uptime_kuma_data:/app/data
|
||||
ports:
|
||||
- "${UPTIME_KUMA_PORT:-3201}:3001"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node extra/healthcheck.js || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
uptime_kuma_data:
|
||||
@@ -76,7 +76,7 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
|
||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Production}"
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
|
||||
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
||||
@@ -177,6 +177,30 @@ services:
|
||||
ports:
|
||||
- "${KOJA_PORT:-3103}:3000"
|
||||
|
||||
# Nightly Postgres backup — dumps the DB every night, keeps the last 14 days.
|
||||
# Dumps land in the host ./backups dir (bind mount) so they survive a full
|
||||
# container/volume wipe and can be rsync'd off-box. See scripts/backup/RESTORE.md.
|
||||
backup:
|
||||
image: ${POSTGRES_IMAGE:-mirror.soroushasadi.com/postgres:16-alpine}
|
||||
container_name: meezi-backup
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGHOST: postgres
|
||||
PGPORT: "5432"
|
||||
PGUSER: meezi
|
||||
PGPASSWORD: "${DB_PASSWORD:-meezi_local_pass}"
|
||||
PGDATABASE: meezi
|
||||
RETAIN_DAYS: "${BACKUP_RETAIN_DAYS:-14}"
|
||||
BACKUP_HOUR: "${BACKUP_HOUR:-2}"
|
||||
TZ: Asia/Tehran
|
||||
entrypoint: ["/bin/sh", "/backup/pg-backup-loop.sh"]
|
||||
volumes:
|
||||
- ./scripts/backup:/backup:ro
|
||||
- ${BACKUP_DIR:-./backups}:/backups
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
@@ -8,6 +8,11 @@ COPY global.json Directory.Build.props Directory.Packages.props ./
|
||||
# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
|
||||
COPY nuget.docker.config ./nuget.config
|
||||
|
||||
# Trust the Nexus mirror's TLS CA (new ISRG Root YR chain, not in the SDK image's
|
||||
# trust store). See docker/api/Dockerfile for the full rationale.
|
||||
COPY docker/nexus-mirror-ca.crt /usr/local/share/ca-certificates/nexus-mirror-ca.crt
|
||||
RUN update-ca-certificates
|
||||
|
||||
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
|
||||
COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/
|
||||
COPY src/Meezi.Infrastructure/Meezi.Infrastructure.csproj src/Meezi.Infrastructure/
|
||||
|
||||
@@ -8,6 +8,12 @@ COPY global.json Directory.Build.props Directory.Packages.props ./
|
||||
# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
|
||||
COPY nuget.docker.config ./nuget.config
|
||||
|
||||
# Trust the Nexus mirror's TLS CA: its Let's Encrypt cert renewed under the new
|
||||
# ISRG Root YR, which isn't in the SDK image's trust store yet. Add the mirror's
|
||||
# intermediate (CA:TRUE, valid to Sept 2028) as an anchor so dotnet restore validates.
|
||||
COPY docker/nexus-mirror-ca.crt /usr/local/share/ca-certificates/nexus-mirror-ca.crt
|
||||
RUN update-ca-certificates
|
||||
|
||||
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
|
||||
COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/
|
||||
COPY src/Meezi.Infrastructure/Meezi.Infrastructure.csproj src/Meezi.Infrastructure/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIE2jCCAsKgAwIBAgIQTr0klH4k05SALYSlL9WzGTANBgkqhkiG9w0BAQsFADAu
|
||||
MQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZUjAe
|
||||
Fw0yNTA5MDMwMDAwMDBaFw0yODA5MDIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYw
|
||||
FAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZUjIwggEiMA0GCSqGSIb3
|
||||
DQEBAQUAA4IBDwAwggEKAoIBAQDZ0LxwBppqh84luqMerV/eeL/fXQ7mLQQv1Lnp
|
||||
WKZbyvGpx6wh6AfnslAnF6ewTkcHA+gSOoBvm3Dfm06AuGiF+KRut4fAcowqnAQQ
|
||||
CW98+QPP/eOv/wug7Iyk4NkOxf2I6g2f55T6nJoOTLFcukeRq80JGQEYan+dPFr9
|
||||
OGUgQK2hGKgNkW87pappsOAuUJcroYhRt5uUis4qaZireiseu32gzDJNBAiKtsvd
|
||||
6HX4v25bpkRNcS/B/Gtc9kVbUpD+2PLPxdei3Tim55k4tfAEXwD2qyiPTxrTNq6l
|
||||
N+AMr5g2c1dNqkOTwjxeV6L5lpP1rGiYvLnRaPlOqyZRPW+5AgMBAAGjge4wgesw
|
||||
DgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBIGA1UdEwEB/wQI
|
||||
MAYBAf8CAQAwHQYDVR0OBBYEFEAVLSZ57TIgnt+ach3WMh+BDIEMMB8GA1UdIwQY
|
||||
MBaAFN7nW2DQIm1AKH0/DQH+pLVStFGUMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEF
|
||||
BQcwAoYWaHR0cDovL3lyLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAEC
|
||||
ATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veXIuYy5sZW5jci5vcmcvMA0GCSqG
|
||||
SIb3DQEBCwUAA4ICAQB0ZUQWZ9/Yn9COEpo+JfecMnB0h0vwDm/M66IqXqw3LoaL
|
||||
mx9lZvRTeDIS67PUeI3yCA2W6PKRD0/FE/G57lOmS+Xy5AaaL00ICGOqjNcCaMWW
|
||||
8o8nevHOd4i4lqgtznE/28QwlcdJyF8yBiWHpnyjhEpmNWJURgOCOg2xpwRMBCsj
|
||||
MScqYPtOhBeuYQvSwAEeTML2Ukh6uGuX4E14q65Ja8cdjF5bAldnP1eE4FBaAwsZ
|
||||
G2fOqqrKV03Y85Nw2btedP1AtliQuJZs/Jo/gXxXdc7LrH3McgnpnbTiAncX7yES
|
||||
hP6kzQejllqMCIt52HOjxDGWafS7Xw+DKwqmH+Eqy8dcbOuag/1AYlQoKNVK3F5q
|
||||
Hh6tEDiMqQcLIibGKteE6iHo4A/bIScbzrhXUYuism42ZYzmc48FMVIH3qy4L84E
|
||||
TdAH2gtxw0PAhvRVXp8HP7wfngpzsN/8xOTpeRSbM4+Qbc56G6+Bifmv6sk1ieQb
|
||||
NA3wJdl4DDUuQSV8hBgx6zoI1ZSGORprDFux7c6rhc77QZMSRrEgomBeklervEve
|
||||
86ylWmZ3WWHV6RLMi8xNvjd71r4EPIGgY7BZU/VPBkq+uA7Gb6mbJnFgV43uh3xy
|
||||
LRFgxIAphIukwTGSMZZR+AI+Qnp0BYTWovHXozOf3H8r6hozEoT02JHn0AeTfA==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,47 @@
|
||||
# Meezi uptime monitoring (Uptime Kuma)
|
||||
|
||||
Self-hosted uptime + TLS-expiry monitoring with alerting. Runs as a separate
|
||||
compose stack so it stays up independently of app deploys.
|
||||
|
||||
## Stand it up (one time, on the prod host)
|
||||
```bash
|
||||
cd /path/to/meezi
|
||||
docker compose -f docker-compose.monitoring.yml up -d
|
||||
```
|
||||
Then either:
|
||||
- add a DNS A record `status.meezi.ir → server IP` and reload Caddy
|
||||
(`docker exec meezi-caddy caddy reload` or restart the caddy stack) — the
|
||||
`status.{$DOMAIN}` block is already in the Caddyfile, **or**
|
||||
- reach it directly at `http://SERVER:3201` for the initial setup.
|
||||
|
||||
First visit creates the admin account — set a strong password.
|
||||
|
||||
## Monitors to add (in the Uptime Kuma UI)
|
||||
Add one **HTTP(s)** monitor per public surface, interval 60s, accept 2xx/3xx:
|
||||
|
||||
| Name | URL | Notes |
|
||||
|------|-----|-------|
|
||||
| Website | https://meezi.ir/fa | marketing |
|
||||
| Dashboard | https://app.meezi.ir/fa/login | merchant panel |
|
||||
| API health | https://api.meezi.ir/api/public/security-config | returns JSON 200 |
|
||||
| Koja | https://koja.meezi.ir/fa | public discovery |
|
||||
| Admin | https://admin.meezi.ir | internal panel |
|
||||
| Guest menu | https://app.meezi.ir/q/healthcheck | should be 200 (not 500) |
|
||||
|
||||
For each HTTPS monitor enable **"Certificate Expiry Notification"** — this
|
||||
catches the recurring ~90-day Let's Encrypt cert-chain breakages early
|
||||
(see the mirror-cert runbook). Set the threshold to 14 days.
|
||||
|
||||
## Alerts
|
||||
Settings → Notifications → add a channel (Telegram bot or email/SMTP), then
|
||||
attach it to every monitor. Telegram is simplest: create a bot via @BotFather,
|
||||
get the chat id, paste both into Uptime Kuma.
|
||||
|
||||
## What this does NOT replace
|
||||
- **Backups** — see `scripts/backup/RESTORE.md`.
|
||||
- **Crash auto-recovery** — Docker `restart: unless-stopped` already restarts
|
||||
crashed containers; Uptime Kuma tells you when one is flapping or down.
|
||||
|
||||
## Status page (optional)
|
||||
Uptime Kuma can publish a public status page (Settings → Status Pages) at
|
||||
`status.meezi.ir/status/meezi` if you want customers to see uptime.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
@@ -0,0 +1,33 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
- platform: android
|
||||
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
- platform: web
|
||||
create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,53 @@
|
||||
# Building meezi_app from Iran (sanctions mirrors)
|
||||
|
||||
`pub.dev`, Google's package storage, and Google's Android maven2 artifacts are
|
||||
sanctions-filtered from Iranian IPs (403 / 404). Use the reachable mirrors below.
|
||||
|
||||
## 1. Environment (set once, persistently)
|
||||
|
||||
```powershell
|
||||
setx PUB_HOSTED_URL "https://pub.flutter-io.cn"
|
||||
setx FLUTTER_STORAGE_BASE_URL "https://storage.flutter-io.cn"
|
||||
```
|
||||
|
||||
These make `flutter pub get`, `flutter create`, and engine/artifact downloads work.
|
||||
**Web already builds** with just these (`flutter build web`).
|
||||
|
||||
## 2. Android — Maven/Gradle mirror
|
||||
|
||||
Google's Android maven2 (AGP, androidx, etc.) 404s here, so:
|
||||
|
||||
- `android/settings.gradle.kts` and `android/build.gradle.kts` already point their
|
||||
repositories at the Aliyun mirrors (committed).
|
||||
- Flutter's **included** `flutter_tools/gradle` build has its own repositories, so add a
|
||||
global Gradle init script. Put this at `%GRADLE_USER_HOME%/init.gradle`
|
||||
(e.g. `C:\gradlecache\init.gradle`, then build with `GRADLE_USER_HOME=C:\gradlecache`):
|
||||
|
||||
```gradle
|
||||
def aliyun = [
|
||||
'https://maven.aliyun.com/repository/gradle-plugin',
|
||||
'https://maven.aliyun.com/repository/google',
|
||||
'https://maven.aliyun.com/repository/central',
|
||||
]
|
||||
beforeSettings { settings ->
|
||||
settings.pluginManagement { repositories { aliyun.each { u -> maven { url u } } } }
|
||||
if (settings.rootDir.path.replace('\\', '/').contains('flutter_tools')) {
|
||||
settings.dependencyResolutionManagement { repositories { aliyun.each { u -> maven { url u } } } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Build
|
||||
|
||||
```powershell
|
||||
$env:GRADLE_USER_HOME = "C:\gradlecache" # keep the cache on a drive with space
|
||||
cd mobile/meezi_app
|
||||
flutter build apk --debug
|
||||
```
|
||||
|
||||
## Status
|
||||
- ✅ `flutter build web` — works.
|
||||
- ✅ Android dependency resolution — works via the Aliyun mirrors (verified).
|
||||
- ⛔ APK build currently blocked only by **disk space** (needs a few GB free for the
|
||||
Gradle cache + build output). Free space (the large Docker WSL vhdx on C: is the
|
||||
obvious reclaim), then `flutter build apk` completes.
|
||||
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ir.meezi.meezi_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "ir.meezi.meezi_app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="meezi_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package ir.meezi.meezi_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,27 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
// Iran: prefer reachable Aliyun mirrors (Google Android maven2 is filtered here).
|
||||
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/central") }
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
@@ -0,0 +1,31 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
// Iran: Google's Android maven2 artifacts 404 here (sanctions-filtered), so
|
||||
// resolve through the reachable Aliyun mirrors first; keep the originals as fallback.
|
||||
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/central") }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Meezi brand palette. Green #0F6E56 matches the dashboard / Koja web.
|
||||
class MeeziColors {
|
||||
static const Color brand = Color(0xFF0F6E56);
|
||||
static const Color brandDark = Color(0xFF0B5544);
|
||||
static const Color accent = Color(0xFFE1F5EE);
|
||||
static const Color surface = Color(0xFFF9FAFB);
|
||||
}
|
||||
|
||||
/// Centralized Meezi theme. Uses Vazirmatn when the font is bundled (see pubspec);
|
||||
/// falls back to the platform font otherwise. Kept to stable Material 3 APIs.
|
||||
class MeeziTheme {
|
||||
static ThemeData light() {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: MeeziColors.brand,
|
||||
primary: MeeziColors.brand,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
fontFamily: 'Vazirmatn',
|
||||
scaffoldBackgroundColor: MeeziColors.surface,
|
||||
appBarTheme: const AppBarTheme(
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: MeeziColors.brand,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: MeeziColors.brand,
|
||||
side: const BorderSide(color: MeeziColors.brand),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.black.withValues(alpha: 0.10)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.black.withValues(alpha: 0.10)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: MeeziColors.brand, width: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData dark() {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: MeeziColors.brand,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
fontFamily: 'Vazirmatn',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,11 +93,64 @@ class _CafeDetailScreenState extends ConsumerState<CafeDetailScreen> {
|
||||
final description = cafe['description'] as String?;
|
||||
final address = cafe['address'] as String?;
|
||||
final city = cafe['city'] as String?;
|
||||
// Defensive parsing — public DTO key names may vary.
|
||||
final cover = (cafe['coverImageUrl'] ?? cafe['coverUrl'] ?? cafe['cover']) as String?;
|
||||
final isOpen = cafe['isOpenNow'] as bool?;
|
||||
final gallery = (cafe['galleryUrls'] ?? cafe['gallery']) is List
|
||||
? ((cafe['galleryUrls'] ?? cafe['gallery']) as List)
|
||||
.map((e) => e.toString())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList()
|
||||
: <String>[];
|
||||
// WorkingHoursPublicDto: a day-keyed object {sat..fri}, each {isOpen,open,close}.
|
||||
final hours = cafe['workingHours'] is Map
|
||||
? (cafe['workingHours'] as Map)
|
||||
: const <dynamic, dynamic>{};
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(name, style: Theme.of(context).textTheme.headlineSmall),
|
||||
if (cover != null && cover.isNotEmpty) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Image.network(
|
||||
cover,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(color: Colors.black12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(name,
|
||||
style: Theme.of(context).textTheme.headlineSmall),
|
||||
),
|
||||
if (isOpen != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: (isOpen ? Colors.green : Colors.red)
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
isOpen ? 'باز است' : 'بسته است',
|
||||
style: TextStyle(
|
||||
color: isOpen ? Colors.green[800] : Colors.red[800],
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
@@ -117,6 +170,59 @@ class _CafeDetailScreenState extends ConsumerState<CafeDetailScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(description),
|
||||
],
|
||||
if (gallery.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 110,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: gallery.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, i) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
gallery[i],
|
||||
width: 150,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(width: 150, color: Colors.black12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hours.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text('ساعات کاری',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
...const [
|
||||
('sat', 'شنبه'),
|
||||
('sun', 'یکشنبه'),
|
||||
('mon', 'دوشنبه'),
|
||||
('tue', 'سهشنبه'),
|
||||
('wed', 'چهارشنبه'),
|
||||
('thu', 'پنجشنبه'),
|
||||
('fri', 'جمعه'),
|
||||
].map((d) {
|
||||
final m = hours[d.$1] is Map
|
||||
? hours[d.$1] as Map
|
||||
: const <dynamic, dynamic>{};
|
||||
final open = (m['open'] ?? '').toString();
|
||||
final close = (m['close'] ?? '').toString();
|
||||
final isOpen = m['isOpen'] == true && open.isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(d.$2),
|
||||
Text(isOpen ? '$open - $close' : 'تعطیل'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
@@ -4,22 +4,91 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../cart/cart_state.dart';
|
||||
|
||||
typedef DiscoverFilters = ({String? q, double? minRating, String sort});
|
||||
/// Discovery filters. A class (not a record) so the many optional filters can be
|
||||
/// changed one at a time via copyWith without re-listing every field.
|
||||
class DiscoverFilters {
|
||||
const DiscoverFilters({
|
||||
this.q,
|
||||
this.minRating,
|
||||
this.sort = 'rating',
|
||||
this.openNow = false,
|
||||
this.priceTier,
|
||||
this.themes = const [],
|
||||
this.vibes = const [],
|
||||
this.occasions = const [],
|
||||
this.spaceFeatures = const [],
|
||||
});
|
||||
|
||||
final discoverFiltersProvider = StateProvider<DiscoverFilters>(
|
||||
(_) => (q: null, minRating: null, sort: 'rating'),
|
||||
);
|
||||
final String? q;
|
||||
final double? minRating;
|
||||
final String sort;
|
||||
final bool openNow;
|
||||
final String? priceTier;
|
||||
final List<String> themes;
|
||||
final List<String> vibes;
|
||||
final List<String> occasions;
|
||||
final List<String> spaceFeatures;
|
||||
|
||||
final discoverProvider = FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) {
|
||||
final filters = ref.watch(discoverFiltersProvider);
|
||||
int get activeCount =>
|
||||
(minRating != null ? 1 : 0) +
|
||||
(openNow ? 1 : 0) +
|
||||
(priceTier != null ? 1 : 0) +
|
||||
themes.length +
|
||||
vibes.length +
|
||||
occasions.length +
|
||||
spaceFeatures.length;
|
||||
|
||||
DiscoverFilters copyWith({
|
||||
ValueGetter<String?>? q,
|
||||
ValueGetter<double?>? minRating,
|
||||
String? sort,
|
||||
bool? openNow,
|
||||
ValueGetter<String?>? priceTier,
|
||||
List<String>? themes,
|
||||
List<String>? vibes,
|
||||
List<String>? occasions,
|
||||
List<String>? spaceFeatures,
|
||||
}) {
|
||||
return DiscoverFilters(
|
||||
q: q != null ? q() : this.q,
|
||||
minRating: minRating != null ? minRating() : this.minRating,
|
||||
sort: sort ?? this.sort,
|
||||
openNow: openNow ?? this.openNow,
|
||||
priceTier: priceTier != null ? priceTier() : this.priceTier,
|
||||
themes: themes ?? this.themes,
|
||||
vibes: vibes ?? this.vibes,
|
||||
occasions: occasions ?? this.occasions,
|
||||
spaceFeatures: spaceFeatures ?? this.spaceFeatures,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final discoverFiltersProvider =
|
||||
StateProvider<DiscoverFilters>((_) => const DiscoverFilters());
|
||||
|
||||
final discoverProvider =
|
||||
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) {
|
||||
final f = ref.watch(discoverFiltersProvider);
|
||||
return ref.watch(publicApiProvider).discover(
|
||||
city: 'تهران',
|
||||
q: filters.q,
|
||||
minRating: filters.minRating,
|
||||
sort: filters.sort,
|
||||
q: f.q,
|
||||
minRating: f.minRating,
|
||||
sort: f.sort,
|
||||
openNow: f.openNow,
|
||||
priceTier: f.priceTier,
|
||||
themes: f.themes,
|
||||
vibes: f.vibes,
|
||||
occasions: f.occasions,
|
||||
spaceFeatures: f.spaceFeatures,
|
||||
);
|
||||
});
|
||||
|
||||
/// Available themes/vibes/occasions/spaceFeatures for the filter sheet.
|
||||
final discoverTaxonomyProvider =
|
||||
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) {
|
||||
return ref.watch(publicApiProvider).discoverTaxonomy();
|
||||
});
|
||||
|
||||
class DiscoverScreen extends ConsumerStatefulWidget {
|
||||
const DiscoverScreen({super.key});
|
||||
|
||||
@@ -39,10 +108,19 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
void _applySearch() {
|
||||
final q = _searchController.text.trim();
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: q.isEmpty ? null : q, minRating: s.minRating, sort: s.sort),
|
||||
(s) => s.copyWith(q: () => q.isEmpty ? null : q),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openFilters() async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const _DiscoverFilterSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cafesAsync = ref.watch(discoverProvider);
|
||||
@@ -70,17 +148,27 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'جستجوی نام کافه...',
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'کافه دنج برای کار، نزدیک من...',
|
||||
isDense: true,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _applySearch,
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _applySearch(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Badge(
|
||||
isLabelVisible: filters.activeCount > 0,
|
||||
label: Text('${filters.activeCount}'),
|
||||
child: IconButton.filledTonal(
|
||||
icon: const Icon(Icons.tune),
|
||||
onPressed: _openFilters,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -90,26 +178,29 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('همه'),
|
||||
selected: filters.minRating == null,
|
||||
onSelected: (_) {
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: s.q, minRating: null, sort: s.sort),
|
||||
);
|
||||
},
|
||||
label: const Text('باز است'),
|
||||
selected: filters.openNow,
|
||||
onSelected: (v) => ref
|
||||
.read(discoverFiltersProvider.notifier)
|
||||
.update((s) => s.copyWith(openNow: v)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: const Text('همه امتیازها'),
|
||||
selected: filters.minRating == null,
|
||||
onSelected: (_) => ref
|
||||
.read(discoverFiltersProvider.notifier)
|
||||
.update((s) => s.copyWith(minRating: () => null)),
|
||||
),
|
||||
for (final min in [3.0, 4.0, 4.5])
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilterChip(
|
||||
label: Text('★ $min+'),
|
||||
selected: filters.minRating == min,
|
||||
onSelected: (_) {
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: s.q, minRating: min, sort: s.sort),
|
||||
);
|
||||
},
|
||||
onSelected: (_) => ref
|
||||
.read(discoverFiltersProvider.notifier)
|
||||
.update((s) => s.copyWith(minRating: () => min)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -118,10 +209,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: filters.sort,
|
||||
initialValue: filters.sort,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'مرتبسازی',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
items: const [
|
||||
@@ -131,9 +221,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
],
|
||||
onChanged: (sort) {
|
||||
if (sort == null) return;
|
||||
ref.read(discoverFiltersProvider.notifier).update(
|
||||
(s) => (q: s.q, minRating: s.minRating, sort: sort),
|
||||
);
|
||||
ref
|
||||
.read(discoverFiltersProvider.notifier)
|
||||
.update((s) => s.copyWith(sort: sort));
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -143,36 +233,19 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
if (cafes.isEmpty) {
|
||||
return const Center(child: Text('کافهای یافت نشد'));
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: cafes.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final cafe = cafes[index];
|
||||
final slug = cafe['slug'] as String;
|
||||
final name = cafe['name'] as String? ?? slug;
|
||||
final avg = (cafe['averageRating'] as num?)?.toDouble() ?? 0;
|
||||
final count = cafe['reviewCount'] as int? ?? 0;
|
||||
final address = cafe['address'] as String?;
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(name),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(cafe['city'] as String? ?? ''),
|
||||
if (address != null && address.isNotEmpty) Text(address),
|
||||
Text('★ ${avg.toStringAsFixed(1)} · $count نظر'),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_left),
|
||||
onTap: () => context.push('/cafe/$slug'),
|
||||
),
|
||||
);
|
||||
},
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(discoverProvider.future),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: cafes.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) =>
|
||||
_CafeCard(cafe: cafes[index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('خطا: $e')),
|
||||
),
|
||||
),
|
||||
@@ -182,3 +255,205 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CafeCard extends StatelessWidget {
|
||||
const _CafeCard({required this.cafe});
|
||||
final Map<String, dynamic> cafe;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final slug = cafe['slug'] as String;
|
||||
final name = cafe['name'] as String? ?? slug;
|
||||
final avg = (cafe['averageRating'] as num?)?.toDouble() ?? 0;
|
||||
final count = cafe['reviewCount'] as int? ?? 0;
|
||||
final address = cafe['address'] as String?;
|
||||
final isOpen = cafe['isOpenNow'] as bool?;
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(name)),
|
||||
if (isOpen != null)
|
||||
Text(
|
||||
isOpen ? 'باز' : 'بسته',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isOpen ? Colors.green : Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(cafe['city'] as String? ?? ''),
|
||||
if (address != null && address.isNotEmpty) Text(address),
|
||||
Text('★ ${avg.toStringAsFixed(1)} · $count نظر'),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_left),
|
||||
onTap: () => context.push('/cafe/$slug'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiscoverFilterSheet extends ConsumerWidget {
|
||||
const _DiscoverFilterSheet();
|
||||
|
||||
static const _priceTiers = [
|
||||
('budget', 'اقتصادی'),
|
||||
('moderate', 'متوسط'),
|
||||
('upscale', 'لاکچری'),
|
||||
('luxury', 'بسیار لاکچری'),
|
||||
];
|
||||
|
||||
List<({String key, String label})> _parseTax(dynamic raw) {
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.map<({String key, String label})>((e) {
|
||||
if (e is Map) {
|
||||
final k = (e['key'] ?? e['value'] ?? e['id'] ?? '').toString();
|
||||
final l = (e['labelFa'] ?? e['label'] ?? e['nameFa'] ?? e['name'] ?? k)
|
||||
.toString();
|
||||
return (key: k, label: l);
|
||||
}
|
||||
final s = e.toString();
|
||||
return (key: s, label: s);
|
||||
})
|
||||
.where((t) => t.key.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final filters = ref.watch(discoverFiltersProvider);
|
||||
final taxonomy = ref.watch(discoverTaxonomyProvider);
|
||||
final notifier = ref.read(discoverFiltersProvider.notifier);
|
||||
|
||||
Widget chips(String title, List<({String key, String label})> items,
|
||||
List<String> selected, void Function(List<String>) onChange) {
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 0, 6),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final it in items)
|
||||
FilterChip(
|
||||
label: Text(it.label),
|
||||
selected: selected.contains(it.key),
|
||||
onSelected: (v) {
|
||||
final next = List<String>.from(selected);
|
||||
if (v) {
|
||||
next.add(it.key);
|
||||
} else {
|
||||
next.remove(it.key);
|
||||
}
|
||||
onChange(next);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
16 + MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('فیلترها',
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
if (filters.activeCount > 0)
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
notifier.state = const DiscoverFilters(),
|
||||
child: const Text('پاک کردن'),
|
||||
),
|
||||
],
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('فقط کافههای باز'),
|
||||
value: filters.openNow,
|
||||
onChanged: (v) => notifier.update((s) => s.copyWith(openNow: v)),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 8, 0, 6),
|
||||
child: Text('محدوده قیمت'),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final p in _priceTiers)
|
||||
ChoiceChip(
|
||||
label: Text(p.$2),
|
||||
selected: filters.priceTier == p.$1,
|
||||
onSelected: (v) => notifier.update(
|
||||
(s) => s.copyWith(priceTier: () => v ? p.$1 : null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
taxonomy.when(
|
||||
data: (tax) {
|
||||
if (tax == null) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
chips('فضا و حالوهوا', _parseTax(tax['themes']),
|
||||
filters.themes,
|
||||
(v) => notifier.update((s) => s.copyWith(themes: v))),
|
||||
chips('وایب', _parseTax(tax['vibes']), filters.vibes,
|
||||
(v) => notifier.update((s) => s.copyWith(vibes: v))),
|
||||
chips('مناسبت', _parseTax(tax['occasions']),
|
||||
filters.occasions,
|
||||
(v) => notifier.update((s) => s.copyWith(occasions: v))),
|
||||
chips('امکانات', _parseTax(tax['spaceFeatures']),
|
||||
filters.spaceFeatures,
|
||||
(v) => notifier.update(
|
||||
(s) => s.copyWith(spaceFeatures: v))),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('نمایش نتایج'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'امروز: ${todayJalali.formatter.yyyyMMdd()}',
|
||||
'امروز: ${todayJalali.year}/${todayJalali.month.toString().padLeft(2, '0')}/${todayJalali.day.toString().padLeft(2, '0')}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -10,12 +10,28 @@ class PublicApi {
|
||||
String? q,
|
||||
double? minRating,
|
||||
String? sort,
|
||||
List<String>? themes,
|
||||
List<String>? vibes,
|
||||
List<String>? occasions,
|
||||
List<String>? spaceFeatures,
|
||||
String? noise,
|
||||
String? priceTier,
|
||||
String? size,
|
||||
bool openNow = false,
|
||||
}) async {
|
||||
final params = <String, String>{};
|
||||
if (city != null && city.isNotEmpty) params['city'] = city;
|
||||
if (q != null && q.isNotEmpty) params['q'] = q;
|
||||
if (minRating != null) params['minRating'] = minRating.toString();
|
||||
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
|
||||
if (themes != null && themes.isNotEmpty) params['themes'] = themes.join(',');
|
||||
if (vibes != null && vibes.isNotEmpty) params['vibes'] = vibes.join(',');
|
||||
if (occasions != null && occasions.isNotEmpty) params['occasions'] = occasions.join(',');
|
||||
if (spaceFeatures != null && spaceFeatures.isNotEmpty) params['spaceFeatures'] = spaceFeatures.join(',');
|
||||
if (noise != null && noise.isNotEmpty) params['noise'] = noise;
|
||||
if (priceTier != null && priceTier.isNotEmpty) params['priceTier'] = priceTier;
|
||||
if (size != null && size.isNotEmpty) params['size'] = size;
|
||||
if (openNow) params['openNow'] = 'true';
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/discover',
|
||||
queryParameters: params.isEmpty ? null : params,
|
||||
@@ -24,6 +40,43 @@ class PublicApi {
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// Cafés near a coordinate, sorted by distance (for "near me").
|
||||
Future<List<Map<String, dynamic>>> discoverNearby({
|
||||
required double lat,
|
||||
required double lng,
|
||||
String? excludeSlug,
|
||||
int limit = 12,
|
||||
}) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/discover/near',
|
||||
queryParameters: {
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
if (excludeSlug != null && excludeSlug.isNotEmpty) 'excludeSlug': excludeSlug,
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
final list = res.data?['data'] as List<dynamic>? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// Parse a free-text query into structured discovery hints (themes/vibes/...).
|
||||
Future<Map<String, dynamic>?> nlpParse(String q) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/discover/nlp-parse',
|
||||
queryParameters: {'q': q},
|
||||
);
|
||||
return res.data?['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
|
||||
/// The discovery taxonomy (available themes, vibes, occasions, space features).
|
||||
Future<Map<String, dynamic>?> discoverTaxonomy() async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/discover-profile/taxonomy',
|
||||
);
|
||||
return res.data?['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getReviews(String slug, {int page = 1}) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/public/cafes/$slug/reviews',
|
||||
|
||||
@@ -39,9 +39,6 @@ class _QrScanScreenState extends ConsumerState<QrScanScreen> {
|
||||
tableNumber: tableNumber ?? '',
|
||||
cafeSlug: slug,
|
||||
);
|
||||
if (tableId != null) {
|
||||
ref.read(cartProvider.notifier).setTable(tableId);
|
||||
}
|
||||
context.push(
|
||||
'/cafe/$slug/menu?tableId=$tableId&tableNumber=$tableNumber',
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app/router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: MeeziApp()));
|
||||
@@ -22,10 +23,9 @@ class MeeziApp extends StatelessWidget {
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6B4F3A)),
|
||||
useMaterial3: true,
|
||||
),
|
||||
theme: MeeziTheme.light(),
|
||||
darkTheme: MeeziTheme.dark(),
|
||||
themeMode: ThemeMode.light,
|
||||
routerConfig: appRouter,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.9.2"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.2.3"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
shamsi_date:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shamsi_date
|
||||
sha256: "0383fddc9bce91e9e08de0c909faf93c3ab3a0e532abd271fb0dcf5d0617487b"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.23"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
state_notifier:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: state_notifier
|
||||
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.3 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
@@ -18,7 +18,7 @@ dependencies:
|
||||
shamsi_date: ^1.1.1
|
||||
flutter_secure_storage: ^9.2.2
|
||||
shared_preferences: ^2.3.2
|
||||
intl: ^0.19.0
|
||||
intl: ^0.20.2
|
||||
uuid: ^4.4.2
|
||||
mobile_scanner: ^5.2.3
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:meezi_app/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App builds without throwing', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const ProviderScope(child: MeeziApp()));
|
||||
expect(find.byType(MaterialApp), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
After Width: | Height: | Size: 917 B |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="meezi_app">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>meezi_app</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "meezi_app",
|
||||
"short_name": "meezi_app",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Meezi database backup & restore
|
||||
|
||||
## How backups work
|
||||
The `meezi-backup` container (in `docker-compose.yml`) runs a nightly `pg_dump`
|
||||
of the whole `meezi` database at **02:00 Asia/Tehran**, gzips it, and keeps the
|
||||
**last 14 days** in the host `./backups` directory (override with `BACKUP_DIR`).
|
||||
Filenames: `meezi_YYYYMMDD_HHMMSS.sql.gz`. One backup is also taken immediately
|
||||
when the container first starts.
|
||||
|
||||
Check it's running / list backups:
|
||||
```bash
|
||||
docker logs meezi-backup --tail 20
|
||||
ls -lh ./backups
|
||||
```
|
||||
|
||||
## ⚠️ Copy backups OFF the server
|
||||
The bind-mounted `./backups` survives a container/volume wipe, but **not a disk
|
||||
failure**. Add an off-box copy (run from the host via cron), e.g.:
|
||||
```bash
|
||||
# rsync to another host nightly at 03:00
|
||||
0 3 * * * rsync -az --delete /path/to/meezi/backups/ user@backup-host:/srv/meezi-backups/
|
||||
```
|
||||
or `rclone copy ./backups remote:meezi-backups` to object storage.
|
||||
|
||||
## Restore
|
||||
1. Pick a dump:
|
||||
```bash
|
||||
ls -lh ./backups # choose e.g. meezi_20260615_020000.sql.gz
|
||||
```
|
||||
2. (Recommended) stop the API so nothing writes mid-restore:
|
||||
```bash
|
||||
docker stop meezi-api
|
||||
```
|
||||
3. Restore into the running Postgres container:
|
||||
```bash
|
||||
gunzip -c ./backups/meezi_20260615_020000.sql.gz \
|
||||
| docker exec -i meezi-db psql -U meezi -d meezi
|
||||
```
|
||||
For a clean restore into an empty DB, drop & recreate first:
|
||||
```bash
|
||||
docker exec -i meezi-db psql -U meezi -d postgres -c "DROP DATABASE meezi;"
|
||||
docker exec -i meezi-db psql -U meezi -d postgres -c "CREATE DATABASE meezi OWNER meezi;"
|
||||
gunzip -c ./backups/<dump>.sql.gz | docker exec -i meezi-db psql -U meezi -d meezi
|
||||
```
|
||||
4. Start the API again (it runs EF migrations on boot, which is a no-op if the
|
||||
dump is current):
|
||||
```bash
|
||||
docker start meezi-api
|
||||
```
|
||||
|
||||
## Manual one-off backup
|
||||
```bash
|
||||
docker exec meezi-db pg_dump -U meezi --no-owner --no-privileges meezi \
|
||||
| gzip -9 > ./backups/meezi_manual_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||
```
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/bin/sh
|
||||
# Nightly Postgres backup loop for Meezi.
|
||||
#
|
||||
# Runs inside a small postgres-image container (has pg_dump/gzip). Every day at
|
||||
# ~02:00 Tehran it dumps the whole database, gzips it, and keeps the last
|
||||
# RETAIN_DAYS files in /backups. Designed to be dead-simple and dependency-free:
|
||||
# no cron daemon, just sleep-until-next-run so it survives container restarts.
|
||||
#
|
||||
# Env:
|
||||
# PGHOST, PGUSER, PGPASSWORD, PGDATABASE — connection (from compose)
|
||||
# RETAIN_DAYS — how many daily dumps to keep (default 14)
|
||||
# BACKUP_HOUR — local hour to run (default 2 = 02:00)
|
||||
set -eu
|
||||
|
||||
RETAIN_DAYS="${RETAIN_DAYS:-14}"
|
||||
BACKUP_HOUR="${BACKUP_HOUR:-2}"
|
||||
OUT_DIR=/backups
|
||||
export TZ="${TZ:-Asia/Tehran}"
|
||||
|
||||
log() { echo "[pg-backup $(date '+%Y-%m-%d %H:%M:%S %Z')] $*"; }
|
||||
|
||||
run_backup() {
|
||||
ts=$(date '+%Y%m%d_%H%M%S')
|
||||
tmp="$OUT_DIR/.meezi_${ts}.sql.gz.partial"
|
||||
final="$OUT_DIR/meezi_${ts}.sql.gz"
|
||||
log "starting dump → $final"
|
||||
# pg_dump streams to gzip; .partial then atomic rename so a crash never
|
||||
# leaves a truncated file that looks like a good backup.
|
||||
if pg_dump --no-owner --no-privileges | gzip -9 > "$tmp"; then
|
||||
mv "$tmp" "$final"
|
||||
size=$(wc -c < "$final" 2>/dev/null || echo '?')
|
||||
log "done ($size bytes)"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
log "ERROR: dump failed"
|
||||
return 1
|
||||
fi
|
||||
# Rotate: delete dumps older than RETAIN_DAYS days.
|
||||
find "$OUT_DIR" -maxdepth 1 -name 'meezi_*.sql.gz' -mtime "+${RETAIN_DAYS}" -print -delete | while read -r f; do
|
||||
log "rotated out $f"
|
||||
done
|
||||
}
|
||||
|
||||
seconds_until_next_run() {
|
||||
now_h=$(date '+%-H'); now_m=$(date '+%-M'); now_s=$(date '+%-S')
|
||||
now=$(( now_h * 3600 + now_m * 60 + now_s ))
|
||||
target=$(( BACKUP_HOUR * 3600 ))
|
||||
if [ "$now" -lt "$target" ]; then
|
||||
echo $(( target - now ))
|
||||
else
|
||||
echo $(( 86400 - now + target ))
|
||||
fi
|
||||
}
|
||||
|
||||
log "backup loop started (retain ${RETAIN_DAYS}d, daily at ${BACKUP_HOUR}:00 ${TZ})"
|
||||
# Take one backup immediately on first boot so we never sit a full day with none.
|
||||
run_backup || true
|
||||
while true; do
|
||||
wait_s=$(seconds_until_next_run)
|
||||
log "next backup in ${wait_s}s"
|
||||
sleep "$wait_s"
|
||||
run_backup || true
|
||||
done
|
||||
@@ -58,6 +58,23 @@ public class AuthController : ControllerBase
|
||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("login-key")]
|
||||
[EnableRateLimiting("auth-otp")]
|
||||
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> LoginWithRecoveryKey(
|
||||
[FromBody] LoginWithRecoveryKeyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Key))
|
||||
return BadRequest(ValidationError("Recovery key is required."));
|
||||
|
||||
var (success, data, code, message) = await _authService.LoginWithRecoveryKeyAsync(request, cancellationToken);
|
||||
if (!success)
|
||||
return ErrorResult(code!, message!);
|
||||
|
||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("send-otp")]
|
||||
[EnableRateLimiting("auth-otp")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
||||
@@ -198,7 +215,10 @@ public class AuthController : ControllerBase
|
||||
ExpiresAt: expiresAt,
|
||||
UserId: userId,
|
||||
CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty,
|
||||
Role: User.FindFirstValue(MeeziClaimTypes.Role) ?? string.Empty,
|
||||
// .NET remaps the short "role" claim to ClaimTypes.Role on inbound; read both.
|
||||
Role: User.FindFirstValue(MeeziClaimTypes.Role)
|
||||
?? User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)
|
||||
?? string.Empty,
|
||||
PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty,
|
||||
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
|
||||
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
|
||||
@@ -221,7 +241,9 @@ public class AuthController : ControllerBase
|
||||
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
||||
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" or "INVALID_KEY" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
"CAFE_SUSPENDED" or "NO_OWNER" => StatusCode(StatusCodes.Status403Forbidden,
|
||||
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))),
|
||||
|
||||
@@ -103,4 +103,25 @@ public class BillingController : ControllerBase
|
||||
|
||||
return Ok(new ApiResponse<BillingStatusDto>(true, data));
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpDelete("api/billing/queued/{paymentId}")]
|
||||
public async Task<IActionResult> CancelQueued(string paymentId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||
return Unauthorized();
|
||||
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
|
||||
|
||||
var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
|
||||
if (!ok)
|
||||
{
|
||||
return code == "NOT_FOUND"
|
||||
? NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? "Not found.")))
|
||||
: BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<object>(true, new { id = paymentId }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,13 @@ public class CafePlatformController : CafeApiControllerBase
|
||||
var plans = await _catalog.GetPlansAsync(cancellationToken);
|
||||
return Ok(new ApiResponse<object>(true, plans));
|
||||
}
|
||||
|
||||
/// <summary>Feature catalog (key → display name / module group) so clients can
|
||||
/// label the FeatureKeys returned by the plans endpoint.</summary>
|
||||
[HttpGet("features-catalog")]
|
||||
public async Task<IActionResult> GetFeaturesCatalog(CancellationToken cancellationToken)
|
||||
{
|
||||
var features = await _catalog.GetFeaturesAsync(cancellationToken);
|
||||
return Ok(new ApiResponse<object>(true, features));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@ public class CafePublicProfileController : CafeApiControllerBase
|
||||
gallery,
|
||||
cafe.InstagramHandle,
|
||||
cafe.WebsiteUrl,
|
||||
ToHoursDto(hours))));
|
||||
ToHoursDto(hours),
|
||||
cafe.ShowOnKoja)));
|
||||
}
|
||||
|
||||
// ── PUT (description / social / hours) ───────────────────────────────────
|
||||
@@ -91,6 +92,10 @@ public class CafePublicProfileController : CafeApiControllerBase
|
||||
if (request.WorkingHours is not null)
|
||||
cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts);
|
||||
|
||||
// Koja (public discovery) listing preference
|
||||
if (request.ShowOnKoja.HasValue)
|
||||
cafe.ShowOnKoja = request.ShowOnKoja.Value;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
|
||||
@@ -101,7 +106,8 @@ public class CafePublicProfileController : CafeApiControllerBase
|
||||
gallery,
|
||||
cafe.InstagramHandle,
|
||||
cafe.WebsiteUrl,
|
||||
ToHoursDto(hours))));
|
||||
ToHoursDto(hours),
|
||||
cafe.ShowOnKoja)));
|
||||
}
|
||||
|
||||
// ── POST gallery/upload ───────────────────────────────────────────────────
|
||||
@@ -207,13 +213,15 @@ public record UpdateCafePublicProfileRequest(
|
||||
string? Description,
|
||||
string? InstagramHandle,
|
||||
string? WebsiteUrl,
|
||||
WorkingHoursPublicDto? WorkingHours);
|
||||
WorkingHoursPublicDto? WorkingHours,
|
||||
bool? ShowOnKoja = null);
|
||||
|
||||
public record CafeProfileEditDto(
|
||||
string? Description,
|
||||
IReadOnlyList<string> GalleryUrls,
|
||||
string? InstagramHandle,
|
||||
string? WebsiteUrl,
|
||||
WorkingHoursPublicDto? WorkingHours);
|
||||
WorkingHoursPublicDto? WorkingHours,
|
||||
bool ShowOnKoja);
|
||||
|
||||
public record GalleryDto(IReadOnlyList<string> GalleryUrls);
|
||||
|
||||
@@ -2,7 +2,9 @@ using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -12,11 +14,16 @@ public class CafeReviewsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IReviewService _reviews;
|
||||
private readonly IValidator<ReplyCafeReviewRequest> _replyValidator;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public CafeReviewsController(IReviewService reviews, IValidator<ReplyCafeReviewRequest> replyValidator)
|
||||
public CafeReviewsController(
|
||||
IReviewService reviews,
|
||||
IValidator<ReplyCafeReviewRequest> replyValidator,
|
||||
IPlatformCatalogService catalog)
|
||||
{
|
||||
_reviews = reviews;
|
||||
_replyValidator = replyValidator;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -41,6 +48,13 @@ public class CafeReviewsController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
// Replying to reviews is a paid feature (Starter+).
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, tier, "review_reply", ct))
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_FEATURE_DISABLED", "Replying to reviews is not included in your plan. Please upgrade.")));
|
||||
|
||||
var validation = await _replyValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
|
||||
@@ -3,10 +3,12 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Cafes;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Branding;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -16,11 +18,16 @@ public class CafeSettingsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IValidator<PatchCafeSettingsRequest> _validator;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public CafeSettingsController(AppDbContext db, IValidator<PatchCafeSettingsRequest> validator)
|
||||
public CafeSettingsController(
|
||||
AppDbContext db,
|
||||
IValidator<PatchCafeSettingsRequest> validator,
|
||||
IPlatformCatalogService catalog)
|
||||
{
|
||||
_db = db;
|
||||
_validator = validator;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -81,7 +88,19 @@ public class CafeSettingsController : CafeApiControllerBase
|
||||
if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim();
|
||||
if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim();
|
||||
if (request.Theme is not null)
|
||||
cafe.ThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
|
||||
{
|
||||
// Custom menu styling is a paid feature (Starter+). Only block an actual change,
|
||||
// so a normal settings save that re-sends the current theme isn't rejected.
|
||||
var newThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
|
||||
if (newThemeJson != cafe.ThemeJson)
|
||||
{
|
||||
var styleTier = tenant.PlanTier ?? PlanTier.Free;
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, styleTier, "custom_menu_styling", ct))
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_FEATURE_DISABLED", "Custom menu styling is not included in your plan. Please upgrade.")));
|
||||
cafe.ThemeJson = newThemeJson;
|
||||
}
|
||||
}
|
||||
if (request.DefaultTaxRate is decimal taxRate)
|
||||
cafe.DefaultTaxRate = taxRate;
|
||||
if (request.AllowBranchTaxOverride is bool allowTax)
|
||||
|
||||
@@ -25,7 +25,9 @@ public class DemoSeedController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
// Demo data is a setup helper; Owner or Manager may run it (matches the
|
||||
// dashboard banner, which is shown to both roles).
|
||||
if (EnsureManager(tenant) is { } managerDenied) return managerDenied;
|
||||
|
||||
var result = await _demoSeed.SeedAsync(cafeId, ct);
|
||||
return Ok(new ApiResponse<DemoSeedResult>(true, result));
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Hr;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
@@ -46,6 +47,93 @@ public class HrController : CafeApiControllerBase
|
||||
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
|
||||
}
|
||||
|
||||
/// <summary>Create a new employee (waiter, cashier, chef, …). Owner/Manager only;
|
||||
/// creating a Manager requires Owner. Optionally sets login credentials in one step.</summary>
|
||||
[HttpPost("employees")]
|
||||
public async Task<IActionResult> CreateEmployee(
|
||||
string cafeId,
|
||||
[FromBody] CreateEmployeeRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
|
||||
IActionResult Invalid(string message, string field) =>
|
||||
BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", message, field)));
|
||||
|
||||
var name = request.Name?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Invalid("Name is required.", "Name");
|
||||
|
||||
var phone = request.Phone?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
return Invalid("Phone is required.", "Phone");
|
||||
|
||||
if (!Enum.IsDefined(typeof(EmployeeRole), request.Role))
|
||||
return Invalid("Invalid role.", "Role");
|
||||
// An Owner is created only at café registration, never via this endpoint.
|
||||
if (request.Role == EmployeeRole.Owner)
|
||||
return Invalid("Cannot create an owner here.", "Role");
|
||||
// Only an Owner may add a Manager.
|
||||
if (request.Role == EmployeeRole.Manager && EnsureOwner(tenant) is { } ownerOnly)
|
||||
return ownerOnly;
|
||||
|
||||
// One employee per phone within a café.
|
||||
var phoneTaken = await _db.Employees
|
||||
.AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null && e.Phone == phone, ct);
|
||||
if (phoneTaken)
|
||||
return Conflict(new ApiResponse<object>(false, null,
|
||||
new ApiError("PHONE_TAKEN", "An employee with this phone already exists.", "Phone")));
|
||||
|
||||
string? branchId = string.IsNullOrWhiteSpace(request.BranchId) ? null : request.BranchId.Trim();
|
||||
if (branchId is not null)
|
||||
{
|
||||
var branchOk = await _db.Branches.AnyAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
|
||||
if (!branchOk) return Invalid("Invalid branch.", "BranchId");
|
||||
}
|
||||
|
||||
var employee = new Employee
|
||||
{
|
||||
Id = $"emp_{Guid.NewGuid():N}"[..24],
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
Name = name,
|
||||
Phone = phone,
|
||||
Role = request.Role,
|
||||
BaseSalary = request.BaseSalary ?? 0m,
|
||||
NationalId = string.IsNullOrWhiteSpace(request.NationalId) ? null : request.NationalId.Trim(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
// Optional: enable password login in the same step.
|
||||
var wantsCreds = !string.IsNullOrWhiteSpace(request.Username) || !string.IsNullOrWhiteSpace(request.Password);
|
||||
if (wantsCreds)
|
||||
{
|
||||
var username = (request.Username ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return Invalid("Username is required when setting a password.", "Username");
|
||||
if ((request.Password ?? string.Empty).Length < 8)
|
||||
return Invalid("Password must be at least 8 characters.", "Password");
|
||||
|
||||
var usernameTaken = await _db.Employees
|
||||
.AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null
|
||||
&& e.Username != null && e.Username.ToLower() == username, ct);
|
||||
if (usernameTaken)
|
||||
return Conflict(new ApiResponse<object>(false, null,
|
||||
new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.", "Username")));
|
||||
|
||||
employee.Username = username;
|
||||
employee.PasswordHash = PasswordHasher.Hash(request.Password!);
|
||||
}
|
||||
|
||||
_db.Employees.Add(employee);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var dto = new EmployeeSummaryDto(employee.Id, employee.Name, employee.Phone, employee.Role, employee.BaseSalary);
|
||||
return Ok(new ApiResponse<EmployeeSummaryDto>(true, dto));
|
||||
}
|
||||
|
||||
[HttpGet("employees/{employeeId}")]
|
||||
public async Task<IActionResult> GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -61,6 +61,19 @@ public class InventoryController : CafeApiControllerBase
|
||||
return Ok(new ApiResponse<object>(true, updated));
|
||||
}
|
||||
|
||||
[HttpDelete("ingredients/{ingredientId}")]
|
||||
public async Task<IActionResult> Delete(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var deleted = await _inventory.DeleteAsync(cafeId, ingredientId, ct);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id = ingredientId }));
|
||||
}
|
||||
|
||||
[HttpPost("ingredients/{ingredientId}/adjust")]
|
||||
public async Task<IActionResult> Adjust(
|
||||
string cafeId,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -81,6 +83,33 @@ public class MediaController : CafeApiControllerBase
|
||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||
|
||||
/// <summary>Media library for this café — previously uploaded files so the UI can
|
||||
/// reuse one instead of re-uploading. Deduplication means each distinct file appears once.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListMedia(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
[FromServices] AppDbContext db,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery] string? kind = null,
|
||||
[FromQuery] int limit = 60)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var query = db.MediaAssets.AsNoTracking().Where(m => m.CafeId == cafeId);
|
||||
if (!string.IsNullOrWhiteSpace(kind))
|
||||
query = query.Where(m => m.Kind == kind);
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Take(Math.Clamp(limit, 1, 200))
|
||||
.Select(m => new MediaAssetDto(
|
||||
m.Id, m.Url, m.Kind, m.ContentType, m.SizeBytes, m.OriginalFileName, m.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(new ApiResponse<List<MediaAssetDto>>(true, items));
|
||||
}
|
||||
|
||||
private async Task<IActionResult> Upload(
|
||||
string cafeId,
|
||||
IFormFile file,
|
||||
@@ -103,3 +132,12 @@ public class MediaController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
public record UploadResultDto(string Url);
|
||||
|
||||
public record MediaAssetDto(
|
||||
string Id,
|
||||
string Url,
|
||||
string Kind,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string? OriginalFileName,
|
||||
DateTime CreatedAt);
|
||||
|
||||
@@ -7,6 +7,7 @@ using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -19,24 +20,27 @@ public class MenuController : CafeApiControllerBase
|
||||
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
||||
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
private const string CategoryLimitMessage =
|
||||
"محدودیت دستهبندی پلن رایگان (۳ دسته). برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
||||
"به سقف دستهبندی منوی پلن شما رسیدید. برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
||||
private const string ItemLimitMessage =
|
||||
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
||||
"به سقف آیتم منوی پلن شما رسیدید. برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
||||
|
||||
public MenuController(
|
||||
IMenuService menuService,
|
||||
IMenuAi3dGenerationService menuAi3d,
|
||||
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
||||
IValidator<CreateMenuItemRequest> createItemValidator,
|
||||
AppDbContext db)
|
||||
AppDbContext db,
|
||||
IPlatformCatalogService catalog)
|
||||
{
|
||||
_menuService = menuService;
|
||||
_menuAi3d = menuAi3d;
|
||||
_createCategoryValidator = createCategoryValidator;
|
||||
_createItemValidator = createItemValidator;
|
||||
_db = db;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpGet("categories")]
|
||||
@@ -59,7 +63,7 @@ public class MenuController : CafeApiControllerBase
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var max = PlanLimits.MaxMenuCategories(tier);
|
||||
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuCategories;
|
||||
if (max != int.MaxValue)
|
||||
{
|
||||
var count = await _db.MenuCategories.CountAsync(
|
||||
@@ -120,7 +124,7 @@ public class MenuController : CafeApiControllerBase
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var max = PlanLimits.MaxMenuItems(tier);
|
||||
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuItems;
|
||||
if (max != int.MaxValue)
|
||||
{
|
||||
var count = await _db.MenuItems.CountAsync(
|
||||
@@ -163,6 +167,15 @@ public class MenuController : CafeApiControllerBase
|
||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpDelete("items/{id}")]
|
||||
public async Task<IActionResult> DeleteItem(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
}
|
||||
|
||||
[HttpGet("ai-3d/usage")]
|
||||
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
private readonly IValidator<RecordPaymentsRequest> _paymentsValidator;
|
||||
private readonly IValidator<AppendOrderItemsRequest> _appendValidator;
|
||||
private readonly IValidator<UpdateOrderSessionRequest> _sessionValidator;
|
||||
private readonly IValidator<CorrectPaymentsRequest> _correctionValidator;
|
||||
|
||||
public OrdersController(
|
||||
IOrderService orderService,
|
||||
@@ -28,7 +29,8 @@ public class OrdersController : CafeApiControllerBase
|
||||
IValidator<UpdateOrderStatusRequest> statusValidator,
|
||||
IValidator<RecordPaymentsRequest> paymentsValidator,
|
||||
IValidator<AppendOrderItemsRequest> appendValidator,
|
||||
IValidator<UpdateOrderSessionRequest> sessionValidator)
|
||||
IValidator<UpdateOrderSessionRequest> sessionValidator,
|
||||
IValidator<CorrectPaymentsRequest> correctionValidator)
|
||||
{
|
||||
_orderService = orderService;
|
||||
_audit = audit;
|
||||
@@ -37,6 +39,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
_paymentsValidator = paymentsValidator;
|
||||
_appendValidator = appendValidator;
|
||||
_sessionValidator = sessionValidator;
|
||||
_correctionValidator = correctionValidator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -63,6 +66,35 @@ public class OrdersController : CafeApiControllerBase
|
||||
return Ok(new ApiResponse<IReadOnlyList<OrderDto>>(true, data));
|
||||
}
|
||||
|
||||
/// <summary>Closed orders (delivered/cancelled) of one Iran-calendar day — the
|
||||
/// browsing surface for اصلاح سند payment corrections.</summary>
|
||||
[HttpGet("closed")]
|
||||
public async Task<IActionResult> GetClosedOrders(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery] string? date = null,
|
||||
[FromQuery] string? branchId = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 30)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureBranchAccess(branchId, tenant) is { } branchDenied) return branchDenied;
|
||||
|
||||
DateOnly day;
|
||||
if (string.IsNullOrWhiteSpace(date)) day = IranCalendar.TodayInIran;
|
||||
else if (!DateOnly.TryParse(date, out day))
|
||||
return BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError("VALIDATION_ERROR", "Invalid date (expected YYYY-MM-DD).", "date")));
|
||||
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize is < 1 or > 100) pageSize = 30;
|
||||
|
||||
var (items, total) = await _orderService.GetClosedOrdersAsync(
|
||||
cafeId, day, branchId, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedApiResponse<OrderDto>(true, items, new PagedMeta(total, page, pageSize)));
|
||||
}
|
||||
|
||||
[HttpGet("live")]
|
||||
public async Task<IActionResult> GetLiveOrders(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -273,6 +305,56 @@ public class OrdersController : CafeApiControllerBase
|
||||
return Ok(new ApiResponse<IReadOnlyList<PaymentDto>>(true, result.Data));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// اصلاح سند — void wrongly-recorded payments and/or record replacements on a
|
||||
/// closed order, atomically, with a mandatory reason. Manager/Owner only;
|
||||
/// the full before/after is written to the immutable audit trail.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/payments/corrections")]
|
||||
public async Task<IActionResult> CorrectPayments(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] CorrectPaymentsRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
var validation = await _correctionValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
// Snapshot the payments before the change so the audit row carries a
|
||||
// complete before/after picture even after later corrections.
|
||||
var before = await _orderService.GetOrderAsync(cafeId, id, cancellationToken);
|
||||
|
||||
var result = await _orderService.CorrectPaymentsAsync(
|
||||
cafeId, id, request, tenant.UserId, cancellationToken);
|
||||
if (!result.Success) return OrderError(result.ErrorCode!, result.Field);
|
||||
|
||||
await _audit.LogAsync(new AuditEntry
|
||||
{
|
||||
Category = "Payment",
|
||||
Action = "PaymentCorrected",
|
||||
EntityType = "Order",
|
||||
EntityId = id,
|
||||
Summary = $"اصلاح سند: voided {request.VoidPaymentIds.Count} payment(s), " +
|
||||
$"recorded {request.Replacements.Count} replacement(s) — {request.Reason}",
|
||||
Details = new
|
||||
{
|
||||
orderId = id,
|
||||
displayNumber = result.Data!.DisplayNumber,
|
||||
reason = request.Reason,
|
||||
voidedPaymentIds = request.VoidPaymentIds,
|
||||
paymentsBefore = before?.Payments.Select(p => new { p.Id, p.Method, p.Amount, p.Status }),
|
||||
paymentsAfter = result.Data.Payments.Select(p => new { p.Id, p.Method, p.Amount, p.Status }),
|
||||
paidAmountAfter = result.Data.PaidAmount,
|
||||
orderTotal = result.Data.Total
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
return Ok(new ApiResponse<OrderDto>(true, result.Data));
|
||||
}
|
||||
|
||||
private IActionResult OrderError(string code, string? field = null) =>
|
||||
code switch
|
||||
{
|
||||
@@ -300,6 +382,10 @@ public class OrdersController : CafeApiControllerBase
|
||||
false, null, new ApiError(code, "Table is being cleaned.", field))),
|
||||
"NO_OPEN_SHIFT" => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Open the cash register shift before taking payment.", field))),
|
||||
"PAYMENT_NOT_FOUND" => NotFound(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Payment not found on this order.", field))),
|
||||
"PAYMENT_ALREADY_REFUNDED" => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Payment is already refunded.", field))),
|
||||
_ => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Invalid order request.", field)))
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ using Meezi.API.Services;
|
||||
using Meezi.API.Utils;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -13,13 +14,21 @@ public class ReportsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IReportService _reports;
|
||||
private readonly IDailyReportService _dailyReports;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public ReportsController(IReportService reports, IDailyReportService dailyReports)
|
||||
public ReportsController(
|
||||
IReportService reports,
|
||||
IDailyReportService dailyReports,
|
||||
IPlatformCatalogService catalog)
|
||||
{
|
||||
_reports = reports;
|
||||
_dailyReports = dailyReports;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
private async Task<int> MaxHistoryDaysAsync(ITenantContext tenant, CancellationToken ct) =>
|
||||
(await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxReportHistoryDays;
|
||||
|
||||
[HttpGet("daily")]
|
||||
public async Task<IActionResult> GetDailySnapshot(
|
||||
string cafeId,
|
||||
@@ -37,7 +46,7 @@ public class ReportsController : CafeApiControllerBase
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid date. Use yyyy-MM-dd.", "date")));
|
||||
|
||||
if (EnsureReportDateAllowed(tenant, reportDate) is { } planError) return planError;
|
||||
if (await EnsureReportDateAllowedAsync(tenant, reportDate, ct) is { } planError) return planError;
|
||||
|
||||
var snapshot = await _dailyReports.GetReportAsync(cafeId, branchId, reportDate, ct);
|
||||
if (snapshot is null)
|
||||
@@ -62,16 +71,16 @@ public class ReportsController : CafeApiControllerBase
|
||||
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
|
||||
|
||||
var today = IranCalendar.TodayInIran;
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||
|
||||
if (!ReportPlanGate.IsDateInRange(tier, startDate, today)
|
||||
|| !ReportPlanGate.IsDateInRange(tier, endDate, today))
|
||||
if (!ReportPlanGate.IsDateInRange(maxDays, startDate, today)
|
||||
|| !ReportPlanGate.IsDateInRange(maxDays, endDate, today))
|
||||
{
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "date")));
|
||||
}
|
||||
|
||||
var clamped = ReportPlanGate.ClampRange(tier, startDate, endDate, today);
|
||||
var clamped = ReportPlanGate.ClampRange(maxDays, startDate, endDate, today);
|
||||
if (clamped is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid date range.", "from")));
|
||||
@@ -91,12 +100,11 @@ public class ReportsController : CafeApiControllerBase
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var maxDays = Core.Constants.PlanLimits.MaxReportHistoryDays(tier);
|
||||
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||
if (days > maxDays && maxDays != int.MaxValue)
|
||||
{
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "days")));
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "days")));
|
||||
}
|
||||
|
||||
days = Math.Min(days, maxDays == int.MaxValue ? 365 : maxDays);
|
||||
@@ -180,14 +188,14 @@ public class ReportsController : CafeApiControllerBase
|
||||
return DateOnly.TryParse(value, out date);
|
||||
}
|
||||
|
||||
private IActionResult? EnsureReportDateAllowed(ITenantContext tenant, DateOnly date)
|
||||
private async Task<IActionResult?> EnsureReportDateAllowedAsync(ITenantContext tenant, DateOnly date, CancellationToken ct)
|
||||
{
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||
var today = IranCalendar.TodayInIran;
|
||||
if (ReportPlanGate.IsDateInRange(tier, date, today))
|
||||
if (ReportPlanGate.IsDateInRange(maxDays, date, today))
|
||||
return null;
|
||||
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(tier), "date")));
|
||||
new ApiError("PLAN_LIMIT_REACHED", ReportPlanGate.LimitMessage(maxDays), "date")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,19 @@ public class ReservationsController : CafeApiControllerBase
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<ReservationDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(
|
||||
string cafeId,
|
||||
string id,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var deleted = await _reservations.DeleteAsync(cafeId, id, ct);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateReservationStatusRequest(ReservationStatus Status);
|
||||
|
||||
@@ -7,33 +7,64 @@ using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Marketing SMS — bring-your-own-provider. Each café configures its OWN
|
||||
/// Kavenegar API key + sender line; the platform does not sell SMS.
|
||||
/// </summary>
|
||||
[Route("api/cafes/{cafeId}/sms")]
|
||||
public class SmsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ISmsMarketingService _smsMarketingService;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
|
||||
|
||||
public SmsController(
|
||||
ISmsMarketingService smsMarketingService,
|
||||
ISmsService smsService,
|
||||
IValidator<SendSmsCampaignRequest> campaignValidator)
|
||||
{
|
||||
_smsMarketingService = smsMarketingService;
|
||||
_smsService = smsService;
|
||||
_campaignValidator = campaignValidator;
|
||||
}
|
||||
|
||||
[HttpGet("settings")]
|
||||
public async Task<IActionResult> GetSettings(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
|
||||
var data = await _smsMarketingService.GetSettingsAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<SmsSettingsDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPut("settings")]
|
||||
public async Task<IActionResult> UpdateSettings(
|
||||
string cafeId,
|
||||
[FromBody] UpdateSmsSettingsRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
|
||||
var (success, data, code, message) = await _smsMarketingService.UpdateSettingsAsync(
|
||||
cafeId, request, cancellationToken);
|
||||
if (!success)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!)))
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<SmsSettingsDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpGet("balance")]
|
||||
public async Task<IActionResult> GetBalance(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var info = await _smsService.GetAccountInfoAsync(cancellationToken);
|
||||
var dto = info is not null
|
||||
? new SmsBalanceDto(info.RemainCredit, info.AccountType, true)
|
||||
: new SmsBalanceDto(0, "master", false);
|
||||
|
||||
var dto = await _smsMarketingService.GetBalanceAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<SmsBalanceDto>(true, dto));
|
||||
}
|
||||
|
||||
@@ -41,10 +72,8 @@ public class SmsController : CafeApiControllerBase
|
||||
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (tenant.PlanTier is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
|
||||
|
||||
var data = await _smsMarketingService.GetUsageAsync(cafeId, tenant.PlanTier.Value, cancellationToken);
|
||||
var data = await _smsMarketingService.GetUsageAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<SmsUsageDto>(true, data));
|
||||
}
|
||||
|
||||
@@ -56,20 +85,18 @@ public class SmsController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (tenant.PlanTier is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
|
||||
|
||||
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var (success, data, code, message) = await _smsMarketingService.SendCampaignAsync(
|
||||
cafeId, tenant.PlanTier.Value, request, cancellationToken);
|
||||
cafeId, request, cancellationToken);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"PLAN_LIMIT_REACHED" => StatusCode(StatusCodes.Status403Forbidden,
|
||||
"SMS_NOT_CONFIGURED" => BadRequest(
|
||||
new ApiResponse<object>(false, null, new ApiError(code, message!))),
|
||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!)))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -11,8 +11,13 @@ namespace Meezi.API.Controllers;
|
||||
public class TerminalsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ITerminalRegistryService _terminals;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public TerminalsController(ITerminalRegistryService terminals) => _terminals = terminals;
|
||||
public TerminalsController(ITerminalRegistryService terminals, IPlatformCatalogService catalog)
|
||||
{
|
||||
_terminals = terminals;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register(
|
||||
@@ -35,7 +40,7 @@ public class TerminalsController : CafeApiControllerBase
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var list = await _terminals.ListAsync(cafeId, ct);
|
||||
var max = PlanLimits.MaxTerminals(tenant.PlanTier ?? PlanTier.Free);
|
||||
var max = (await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxTerminals;
|
||||
return Ok(new ApiResponse<object>(true, new { terminals = list, max }));
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,9 @@ public static class ServiceCollectionExtensions
|
||||
app.UseMeeziSecurity();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<Middleware.TenantMiddleware>();
|
||||
// After tenant context (keys are scoped per café), before plan-limit + controllers
|
||||
// so a replayed write short-circuits without re-consuming limits or re-executing.
|
||||
app.UseMiddleware<Middleware.IdempotencyMiddleware>();
|
||||
app.UseMiddleware<Middleware.PlanLimitMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -242,6 +245,11 @@ public static class ServiceCollectionExtensions
|
||||
"branch-permanent-delete",
|
||||
job => job.ExecuteAsync(),
|
||||
Cron.Hourly);
|
||||
|
||||
RecurringJob.AddOrUpdate<IdempotencyCleanupJob>(
|
||||
"idempotency-cleanup",
|
||||
job => job.ExecuteAsync(),
|
||||
Cron.Daily(4));
|
||||
}
|
||||
|
||||
return app;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Purges old idempotency records. Keys only need to outlive realistic offline
|
||||
/// gaps and client retries, so a short retention keeps the table small.
|
||||
/// </summary>
|
||||
public class IdempotencyCleanupJob
|
||||
{
|
||||
private static readonly TimeSpan Retention = TimeSpan.FromDays(7);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<IdempotencyCleanupJob> _logger;
|
||||
|
||||
public IdempotencyCleanupJob(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<IdempotencyCleanupJob> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var cutoff = DateTime.UtcNow - Retention;
|
||||
var removed = await db.IdempotencyRecords
|
||||
.Where(r => r.CreatedAt < cutoff)
|
||||
.ExecuteDeleteAsync();
|
||||
if (removed > 0)
|
||||
_logger.LogInformation("Purged {Count} idempotency records older than {Days}d", removed, Retention.TotalDays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Makes mutating requests safe to retry. A client (e.g. the offline outbox)
|
||||
/// attaches an <c>Idempotency-Key</c> header; if the same key is seen again, the
|
||||
/// original response is replayed instead of executing the write twice.
|
||||
///
|
||||
/// Bookkeeping runs in isolated DI scopes so it never mixes with the controller's
|
||||
/// own DbContext unit of work. Opt-in via header → non-idempotent and binary/file
|
||||
/// endpoints are unaffected unless the client explicitly sends a key.
|
||||
/// </summary>
|
||||
public class IdempotencyMiddleware
|
||||
{
|
||||
private const string HeaderName = "Idempotency-Key";
|
||||
private const int MaxKeyLength = 200;
|
||||
private const int MaxStoredBodyBytes = 256 * 1024;
|
||||
/// <summary>An InProgress record older than this is assumed crashed mid-flight and re-run.</summary>
|
||||
private static readonly TimeSpan StaleInProgress = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<IdempotencyMiddleware> _logger;
|
||||
|
||||
public IdempotencyMiddleware(RequestDelegate next, ILogger<IdempotencyMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ITenantContext tenant, IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
var method = context.Request.Method;
|
||||
var isMutating = HttpMethods.IsPost(method) || HttpMethods.IsPut(method)
|
||||
|| HttpMethods.IsPatch(method) || HttpMethods.IsDelete(method);
|
||||
|
||||
if (!isMutating || !context.Request.Headers.TryGetValue(HeaderName, out var headerValues))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var key = headerValues.ToString();
|
||||
if (string.IsNullOrWhiteSpace(key) || key.Length > MaxKeyLength)
|
||||
{
|
||||
// Unusable key — behave as if it wasn't sent rather than reject the write.
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var scope = string.IsNullOrEmpty(tenant.CafeId) ? "global" : tenant.CafeId;
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
|
||||
// 1) Look for an existing record for this (tenant, key).
|
||||
await using (var lookupScope = scopeFactory.CreateAsyncScope())
|
||||
{
|
||||
var db = lookupScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var existing = await db.IdempotencyRecords.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Scope == scope && r.Key == key, context.RequestAborted);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.Status == IdempotencyStatus.Completed)
|
||||
{
|
||||
await ReplayAsync(context, existing);
|
||||
return;
|
||||
}
|
||||
if (DateTime.UtcNow - existing.CreatedAt < StaleInProgress)
|
||||
{
|
||||
await WriteConflictAsync(context); // genuine concurrent duplicate
|
||||
return;
|
||||
}
|
||||
// Stale reservation (process likely crashed mid-flight) — drop and re-run.
|
||||
_logger.LogWarning("Recovering stale idempotency reservation {Key} for scope {Scope}", key, scope);
|
||||
var stale = await db.IdempotencyRecords
|
||||
.FirstOrDefaultAsync(r => r.Id == existing.Id, context.RequestAborted);
|
||||
if (stale is not null)
|
||||
{
|
||||
db.IdempotencyRecords.Remove(stale);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Reserve the key. The unique (Scope, Key) index serializes racing first requests.
|
||||
var record = new IdempotencyRecord
|
||||
{
|
||||
Scope = scope,
|
||||
Key = key,
|
||||
Method = method,
|
||||
Path = path,
|
||||
Status = IdempotencyStatus.InProgress,
|
||||
};
|
||||
try
|
||||
{
|
||||
await using var reserveScope = scopeFactory.CreateAsyncScope();
|
||||
var db = reserveScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.IdempotencyRecords.Add(record);
|
||||
await db.SaveChangesAsync(context.RequestAborted);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await WriteConflictAsync(context); // another request won the reservation race
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Run the real request, capturing its response.
|
||||
var originalBody = context.Response.Body;
|
||||
await using var buffer = new MemoryStream();
|
||||
context.Response.Body = buffer;
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch
|
||||
{
|
||||
context.Response.Body = originalBody;
|
||||
await DeleteAsync(scopeFactory, record.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
var statusCode = context.Response.StatusCode;
|
||||
buffer.Position = 0;
|
||||
var bytes = buffer.ToArray();
|
||||
context.Response.Body = originalBody;
|
||||
if (bytes.Length > 0)
|
||||
await originalBody.WriteAsync(bytes, context.RequestAborted);
|
||||
|
||||
// 4) Persist the result so retries replay it — except 5xx, which is transient and
|
||||
// released so the client can retry the same key.
|
||||
if (statusCode is >= 200 and < 500)
|
||||
{
|
||||
var storedBody = bytes.Length is > 0 and <= MaxStoredBodyBytes
|
||||
? Encoding.UTF8.GetString(bytes)
|
||||
: null;
|
||||
await CompleteAsync(scopeFactory, record.Id, statusCode, storedBody);
|
||||
}
|
||||
else
|
||||
{
|
||||
await DeleteAsync(scopeFactory, record.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReplayAsync(HttpContext context, IdempotencyRecord record)
|
||||
{
|
||||
context.Response.StatusCode = record.ResponseStatusCode;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
context.Response.Headers["Idempotent-Replay"] = "true";
|
||||
if (!string.IsNullOrEmpty(record.ResponseBody))
|
||||
await context.Response.WriteAsync(record.ResponseBody);
|
||||
}
|
||||
|
||||
private static async Task WriteConflictAsync(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await context.Response.WriteAsync(
|
||||
"{\"success\":false,\"data\":null,\"error\":{\"code\":\"IDEMPOTENCY_IN_PROGRESS\",\"message\":\"A request with this key is still being processed.\"}}");
|
||||
}
|
||||
|
||||
private static async Task CompleteAsync(IServiceScopeFactory f, string id, int status, string? body)
|
||||
{
|
||||
await using var s = f.CreateAsyncScope();
|
||||
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (rec is null) return;
|
||||
rec.Status = IdempotencyStatus.Completed;
|
||||
rec.ResponseStatusCode = status;
|
||||
rec.ResponseBody = body;
|
||||
rec.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task DeleteAsync(IServiceScopeFactory f, string id)
|
||||
{
|
||||
await using var s = f.CreateAsyncScope();
|
||||
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (rec is null) return;
|
||||
db.IdempotencyRecords.Remove(rec);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,12 @@ public class TenantMiddleware
|
||||
{
|
||||
scopedMerchant.CafeId = cafeId;
|
||||
|
||||
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value;
|
||||
// .NET's JWT handler remaps the short "role" claim to ClaimTypes.Role
|
||||
// on inbound, so FindFirst("role") returns null and tenant.Role would
|
||||
// stay null — making EnsureManager/EnsureOwner reject even a real owner.
|
||||
// Read both the raw claim and the mapped one.
|
||||
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value
|
||||
?? context.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
|
||||
if (Enum.TryParse<EmployeeRole>(roleClaim, ignoreCase: true, out var role))
|
||||
scopedMerchant.Role = role;
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ 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);
|
||||
|
||||
/// <summary>Admin-issued recovery key login — logs the café Owner in when OTP access is lost.</summary>
|
||||
public record LoginWithRecoveryKeyRequest(string Key);
|
||||
|
||||
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
|
||||
|
||||
public record RefreshTokenRequest(string RefreshToken);
|
||||
|
||||
@@ -15,13 +15,20 @@ public record BillingStatusDto(
|
||||
int? OrdersDailyLimit,
|
||||
int CustomersCount,
|
||||
int? CustomersLimit,
|
||||
int SmsUsedThisMonth,
|
||||
int SmsMonthlyLimit,
|
||||
bool Menu3dEnabled,
|
||||
bool MenuAi3dEnabled,
|
||||
int MenuAi3dUsedThisMonth,
|
||||
int MenuAi3dMonthlyLimit,
|
||||
bool DiscoverProfileEnabled,
|
||||
bool IsPlanExpired);
|
||||
bool IsPlanExpired,
|
||||
IReadOnlyList<QueuedPlanDto> QueuedPlans);
|
||||
|
||||
public record QueuedPlanDto(
|
||||
string PaymentId,
|
||||
PlanTier PlanTier,
|
||||
int Months,
|
||||
DateTime EffectiveFrom,
|
||||
DateTime EffectiveTo,
|
||||
decimal AmountToman);
|
||||
|
||||
public record BillingVerifyResult(bool Success, string RedirectUrl);
|
||||
|
||||
@@ -13,3 +13,11 @@ public record SmsUsageDto(int UsedThisMonth, int MonthlyLimit, string Month);
|
||||
|
||||
/// <summary>Kavenegar account credit balance returned to the dashboard.</summary>
|
||||
public record SmsBalanceDto(long RemainCredit, string AccountType, bool IsConfigured);
|
||||
|
||||
/// <summary>
|
||||
/// Café's own SMS provider settings (bring-your-own-provider). The API key is
|
||||
/// returned masked — only the last 4 characters are ever echoed back.
|
||||
/// </summary>
|
||||
public record SmsSettingsDto(bool IsConfigured, string? ApiKeyMasked, string? SenderNumber);
|
||||
|
||||
public record UpdateSmsSettingsRequest(string? ApiKey, string? SenderNumber);
|
||||
|
||||
@@ -62,3 +62,15 @@ 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);
|
||||
|
||||
/// <summary>Create a new employee. Owner/Manager only; Manager role requires Owner.
|
||||
/// Username+Password are optional and, when supplied, enable dashboard/POS login.</summary>
|
||||
public record CreateEmployeeRequest(
|
||||
string Name,
|
||||
string Phone,
|
||||
EmployeeRole Role,
|
||||
string? BranchId = null,
|
||||
decimal? BaseSalary = null,
|
||||
string? NationalId = null,
|
||||
string? Username = null,
|
||||
string? Password = null);
|
||||
|
||||
@@ -70,6 +70,17 @@ public record RecordPaymentsRequest(
|
||||
IReadOnlyList<CreatePaymentRequest> Payments,
|
||||
int? LoyaltyPointsToRedeem = null);
|
||||
|
||||
/// <summary>
|
||||
/// اصلاح سند — amend the payments of an order after the fact (wrong method,
|
||||
/// wrong amount, or payment recorded on the wrong order). Voids the listed
|
||||
/// payments (marked Refunded, never deleted) and records the replacements in
|
||||
/// one atomic operation. A reason is mandatory; the whole change is audit-logged.
|
||||
/// </summary>
|
||||
public record CorrectPaymentsRequest(
|
||||
IReadOnlyList<string> VoidPaymentIds,
|
||||
IReadOnlyList<CreatePaymentRequest> Replacements,
|
||||
string Reason);
|
||||
|
||||
public record PaymentDto(string Id, PaymentMethod Method, decimal Amount, PaymentStatus Status, string? Reference);
|
||||
|
||||
public record LiveOrderDto(
|
||||
@@ -80,4 +91,5 @@ public record LiveOrderDto(
|
||||
OrderType OrderType,
|
||||
decimal Total,
|
||||
DateTime CreatedAt,
|
||||
IReadOnlyList<OrderItemDto> Items);
|
||||
IReadOnlyList<OrderItemDto> Items,
|
||||
OrderSource Source);
|
||||
|
||||
@@ -107,7 +107,8 @@ public record PublicMenuDto(
|
||||
string CafeName,
|
||||
string Slug,
|
||||
CafeThemeDto Theme,
|
||||
IReadOnlyList<PublicMenuCategoryDto> Categories);
|
||||
IReadOnlyList<PublicMenuCategoryDto> Categories,
|
||||
bool ShowWatermark);
|
||||
|
||||
public record GuestCreateOrderRequest(
|
||||
OrderType OrderType,
|
||||
|
||||
@@ -253,7 +253,9 @@ public class AuthService : IAuthService
|
||||
if (employee?.Cafe is null)
|
||||
return (false, null, "NOT_FOUND", "User no longer exists.");
|
||||
|
||||
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
// Note: we intentionally do NOT revoke the presented refresh token here.
|
||||
// It is reused (with a slid TTL) so concurrent refreshes from multiple
|
||||
// tabs/devices stay valid instead of racing each other into a logout.
|
||||
|
||||
var allMemberships = await _db.Employees
|
||||
.Include(e => e.Cafe)
|
||||
@@ -265,7 +267,9 @@ public class AuthService : IAuthService
|
||||
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
|
||||
.ToList();
|
||||
|
||||
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken);
|
||||
var tokens = await IssueTokensAsync(
|
||||
employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken,
|
||||
existingRefreshToken: request.RefreshToken);
|
||||
return (true, tokens, null, null);
|
||||
}
|
||||
|
||||
@@ -505,17 +509,62 @@ public class AuthService : IAuthService
|
||||
return (true, tokens, null, null, null);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithRecoveryKeyAsync(
|
||||
LoginWithRecoveryKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Key))
|
||||
return (false, null, "INVALID_KEY", "Invalid recovery key.");
|
||||
|
||||
var hash = RecoveryKeyGenerator.HashOf(request.Key);
|
||||
|
||||
// Exact-hash lookup — the unique index makes this a single index seek.
|
||||
var cafe = await _db.Cafes
|
||||
.FirstOrDefaultAsync(c => c.RecoveryKeyHash == hash && c.DeletedAt == null, cancellationToken);
|
||||
if (cafe is null)
|
||||
return (false, null, "INVALID_KEY", "Invalid recovery key.");
|
||||
|
||||
if (cafe.IsSuspended)
|
||||
return (false, null, "CAFE_SUSPENDED", "This café is suspended. Contact support.");
|
||||
|
||||
// The key authenticates as the café's Owner.
|
||||
var owner = await _db.Employees
|
||||
.Include(e => e.Cafe)
|
||||
.FirstOrDefaultAsync(
|
||||
e => e.CafeId == cafe.Id && e.Role == EmployeeRole.Owner && e.DeletedAt == null,
|
||||
cancellationToken);
|
||||
if (owner?.Cafe is null)
|
||||
return (false, null, "NO_OWNER", "This café has no owner account.");
|
||||
|
||||
_logger.LogWarning(
|
||||
"Recovery-key login for café {CafeId} as owner {OwnerId}", cafe.Id, owner.Id);
|
||||
|
||||
var membershipDtos = new List<CafeMembershipDto>
|
||||
{
|
||||
new(owner.CafeId, owner.Cafe.Name, owner.Role.ToString(), owner.Cafe.PlanTier.ToString())
|
||||
};
|
||||
|
||||
var tokens = await IssueTokensAsync(owner, owner.Cafe, membershipDtos, null, cancellationToken);
|
||||
return (true, tokens, null, null);
|
||||
}
|
||||
|
||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||
Core.Entities.Employee employee,
|
||||
Core.Entities.Cafe cafe,
|
||||
List<CafeMembershipDto>? memberships,
|
||||
string? requestedBranchId,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
string? existingRefreshToken = null)
|
||||
{
|
||||
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
|
||||
|
||||
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
|
||||
var refreshToken = _jwtTokenService.CreateRefreshToken();
|
||||
// On refresh, reuse the caller's refresh token (and slide its TTL below) instead
|
||||
// of minting a new one. A café often runs POS + KDS + queue display at once; if
|
||||
// refresh rotated the token, the first refresh would revoke it and every other
|
||||
// concurrent refresh would get INVALID_TOKEN → forced logout → OTP storm.
|
||||
// Mint a fresh token only on a real login (existingRefreshToken == null).
|
||||
var refreshToken = existingRefreshToken ?? _jwtTokenService.CreateRefreshToken();
|
||||
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
|
||||
|
||||
await _refreshTokenStore.StoreAsync(
|
||||
|
||||
@@ -35,6 +35,11 @@ public interface IBillingService
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
|
||||
string cafeId,
|
||||
string paymentId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class BillingService : IBillingService
|
||||
@@ -210,31 +215,161 @@ public class BillingService : IBillingService
|
||||
return new BillingVerifyResult(false, failUrl);
|
||||
}
|
||||
|
||||
payment.Status = SubscriptionPaymentStatus.Completed;
|
||||
payment.RefId = verify.RefId;
|
||||
|
||||
var cafe = payment.Cafe;
|
||||
cafe.PlanTier = payment.PlanTier;
|
||||
var baseDate = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt > DateTime.UtcNow
|
||||
? cafe.PlanExpiresAt.Value
|
||||
: DateTime.UtcNow;
|
||||
cafe.PlanExpiresAt = baseDate.AddMonths(payment.Months);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Where does the current paid coverage end? = the latest of the active plan's expiry
|
||||
// and the furthest-out already-queued period. A new purchase is appended to that.
|
||||
var coverageEnd = await ComputeCoverageEndAsync(cafe, payment.Id, now, cancellationToken);
|
||||
|
||||
payment.EffectiveFrom = coverageEnd;
|
||||
payment.EffectiveTo = coverageEnd.AddMonths(payment.Months);
|
||||
|
||||
var queued = coverageEnd > now;
|
||||
if (queued)
|
||||
{
|
||||
// The owner already has active/queued coverage → book this one after it.
|
||||
payment.Status = SubscriptionPaymentStatus.Scheduled;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No active coverage → activate immediately.
|
||||
payment.Status = SubscriptionPaymentStatus.Completed;
|
||||
cafe.PlanTier = payment.PlanTier;
|
||||
cafe.PlanExpiresAt = payment.EffectiveTo;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await TrySendConfirmationSmsAsync(cafe, payment, cancellationToken);
|
||||
await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
|
||||
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
}
|
||||
|
||||
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
|
||||
/// and the furthest-out scheduled (queued) period. Returns <paramref name="now"/> if neither
|
||||
/// extends past now (i.e. nothing active/queued).</summary>
|
||||
private async Task<DateTime> ComputeCoverageEndAsync(
|
||||
Cafe cafe, string? excludePaymentId, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var end = now;
|
||||
if (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > end)
|
||||
end = cafe.PlanExpiresAt.Value;
|
||||
|
||||
var lastScheduledEnd = await _db.SubscriptionPayments
|
||||
.Where(p => p.CafeId == cafe.Id
|
||||
&& p.Status == SubscriptionPaymentStatus.Scheduled
|
||||
&& (excludePaymentId == null || p.Id != excludePaymentId)
|
||||
&& p.EffectiveTo != null)
|
||||
.OrderByDescending(p => p.EffectiveTo)
|
||||
.Select(p => p.EffectiveTo)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (lastScheduledEnd.HasValue && lastScheduledEnd.Value > end)
|
||||
end = lastScheduledEnd.Value;
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
/// <summary>When the active plan has lapsed, promote due queued periods to active.
|
||||
/// Loops so a fully-elapsed short queued period doesn't strand the next one.</summary>
|
||||
private async Task PromoteDueScheduledAsync(string cafeId, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
||||
if (cafe is null) return;
|
||||
|
||||
var changed = false;
|
||||
while (!(cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now))
|
||||
{
|
||||
var next = await _db.SubscriptionPayments
|
||||
.Where(p => p.CafeId == cafeId
|
||||
&& p.Status == SubscriptionPaymentStatus.Scheduled
|
||||
&& p.EffectiveFrom != null && p.EffectiveFrom <= now)
|
||||
.OrderBy(p => p.EffectiveFrom)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (next is null) break;
|
||||
|
||||
cafe.PlanTier = next.PlanTier;
|
||||
cafe.PlanExpiresAt = next.EffectiveTo;
|
||||
next.Status = SubscriptionPaymentStatus.Completed;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
|
||||
string cafeId,
|
||||
string paymentId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payment = await _db.SubscriptionPayments
|
||||
.FirstOrDefaultAsync(p => p.Id == paymentId && p.CafeId == cafeId, cancellationToken);
|
||||
if (payment is null)
|
||||
return (false, "NOT_FOUND", "Subscription not found.");
|
||||
|
||||
// Only a queued (not-yet-started) subscription can be cancelled. The active prepaid
|
||||
// plan keeps running until its paid time ends.
|
||||
if (payment.Status != SubscriptionPaymentStatus.Scheduled)
|
||||
return (false, "NOT_CANCELLABLE", "Only a queued subscription can be cancelled.");
|
||||
|
||||
payment.Status = SubscriptionPaymentStatus.Cancelled;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Re-pack the remaining queue so later periods slide earlier to fill the gap.
|
||||
await RecomputeQueueAsync(cafeId, cancellationToken);
|
||||
return (true, null, null);
|
||||
}
|
||||
|
||||
/// <summary>Re-sequences the remaining queued periods contiguously after the active plan
|
||||
/// (purchase order preserved), so cancelling one in the middle doesn't leave a gap.</summary>
|
||||
private async Task RecomputeQueueAsync(string cafeId, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
||||
if (cafe is null) return;
|
||||
|
||||
var anchor = (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now)
|
||||
? cafe.PlanExpiresAt.Value
|
||||
: now;
|
||||
|
||||
var scheduled = await _db.SubscriptionPayments
|
||||
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled)
|
||||
.OrderBy(p => p.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var s in scheduled)
|
||||
{
|
||||
s.EffectiveFrom = anchor;
|
||||
s.EffectiveTo = anchor.AddMonths(s.Months);
|
||||
anchor = s.EffectiveTo.Value;
|
||||
}
|
||||
|
||||
if (scheduled.Count > 0) await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<BillingStatusDto?> GetStatusAsync(
|
||||
string cafeId,
|
||||
PlanTier currentTier,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Lazily activate any queued plan whose start date has passed before reading status.
|
||||
await PromoteDueScheduledAsync(cafeId, cancellationToken);
|
||||
|
||||
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null) return null;
|
||||
|
||||
var queuedPlans = await _db.SubscriptionPayments.AsNoTracking()
|
||||
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled
|
||||
&& p.EffectiveFrom != null && p.EffectiveTo != null)
|
||||
.OrderBy(p => p.EffectiveFrom)
|
||||
.Select(p => new QueuedPlanDto(
|
||||
p.Id, p.PlanTier, p.Months, p.EffectiveFrom!.Value, p.EffectiveTo!.Value, p.AmountToman))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var todayStart = DateTime.UtcNow.Date;
|
||||
var ordersToday = await _db.Orders.CountAsync(
|
||||
o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
|
||||
@@ -244,12 +379,7 @@ public class BillingService : IBillingService
|
||||
|
||||
var maxOrders = PlanLimits.MaxOrdersPerDay(cafe.PlanTier);
|
||||
var maxCustomers = PlanLimits.MaxCustomers(cafe.PlanTier);
|
||||
var maxSms = PlanLimits.MaxSmsPerMonth(cafe.PlanTier);
|
||||
|
||||
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
|
||||
var redis = _redis.GetDatabase();
|
||||
var smsUsed = await redis.StringGetAsync(monthKey);
|
||||
var smsUsedCount = smsUsed.HasValue ? (int)smsUsed : 0;
|
||||
|
||||
var menu3d = await _platformCatalog.IsFeatureEnabledForCafeAsync(
|
||||
cafeId, cafe.PlanTier, FeatureMenu3d, cancellationToken);
|
||||
@@ -271,19 +401,19 @@ public class BillingService : IBillingService
|
||||
maxOrders == int.MaxValue ? null : maxOrders,
|
||||
customersCount,
|
||||
maxCustomers == int.MaxValue ? null : maxCustomers,
|
||||
smsUsedCount,
|
||||
maxSms == int.MaxValue ? -1 : maxSms,
|
||||
menu3d,
|
||||
menuAi3d,
|
||||
ai3dUsedCount,
|
||||
ai3dLimit,
|
||||
discoverProfile,
|
||||
isExpired);
|
||||
isExpired,
|
||||
queuedPlans);
|
||||
}
|
||||
|
||||
private async Task TrySendConfirmationSmsAsync(
|
||||
Cafe cafe,
|
||||
SubscriptionPayment payment,
|
||||
bool queued,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerPhone = await _db.Employees
|
||||
@@ -293,8 +423,9 @@ public class BillingService : IBillingService
|
||||
|
||||
if (string.IsNullOrEmpty(ownerPhone)) return;
|
||||
|
||||
var message =
|
||||
$"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
|
||||
var message = queued
|
||||
? $"میزی: اشتراک {payment.PlanTier} ثبت شد و از {payment.EffectiveFrom:yyyy-MM-dd} (پس از پایان اشتراک فعلی) آغاز میشود. مبلغ: {payment.AmountToman:N0} ت"
|
||||
: $"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
|
||||
try
|
||||
{
|
||||
await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken);
|
||||
|
||||
@@ -345,5 +345,6 @@ public class DeliveryOrderProcessor : IDeliveryOrderProcessor
|
||||
i.UnitPrice,
|
||||
i.Notes,
|
||||
i.IsVoided,
|
||||
i.VoidedAt)).ToList());
|
||||
i.VoidedAt)).ToList(),
|
||||
o.Source);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ public class DemoSeedService : IDemoSeedService
|
||||
// 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))
|
||||
// IgnoreQueryFilters: soft-deleted rows still occupy the PK; re-seeding
|
||||
// after a user deletes demo data must see those rows to avoid a PK collision.
|
||||
if (!await _db.Taxes.IgnoreQueryFilters().AnyAsync(t => t.CafeId == cafeId && t.IsDefault, ct))
|
||||
{
|
||||
_db.Taxes.Add(new Tax
|
||||
{
|
||||
@@ -51,6 +53,7 @@ public class DemoSeedService : IDemoSeedService
|
||||
else
|
||||
{
|
||||
taxId = await _db.Taxes
|
||||
.IgnoreQueryFilters()
|
||||
.Where(t => t.CafeId == cafeId && t.IsDefault)
|
||||
.Select(t => t.Id)
|
||||
.FirstAsync(ct);
|
||||
@@ -130,7 +133,10 @@ public class DemoSeedService : IDemoSeedService
|
||||
decimal qty, decimal reorder, decimal cost, decimal par) =>
|
||||
new()
|
||||
{
|
||||
Id = $"{cafeId}_ing_{Guid.NewGuid():N}"[..36],
|
||||
// No [..36] truncation: Id is a text column, and truncating to 36 chars
|
||||
// cuts off the unique guid for real (32-char) café ids → every row gets
|
||||
// the same id → PK collision → 500. Keep the full unique id.
|
||||
Id = $"{cafeId}_ing_{Guid.NewGuid():N}",
|
||||
CafeId = cafeId,
|
||||
Name = name,
|
||||
Unit = unit,
|
||||
@@ -160,7 +166,9 @@ public class DemoSeedService : IDemoSeedService
|
||||
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
|
||||
new()
|
||||
{
|
||||
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}"[..36],
|
||||
// No [..36] truncation (see Ingredient above): truncating cuts the guid
|
||||
// for real 32-char café ids → identical ids → PK collision → 500.
|
||||
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}",
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
Number = number,
|
||||
|
||||
@@ -20,6 +20,11 @@ public interface IAuthService
|
||||
LoginWithPasswordRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Log in the café Owner using an admin-issued permanent recovery key.</summary>
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithRecoveryKeyAsync(
|
||||
LoginWithRecoveryKeyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
|
||||
string employeeId, string targetCafeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -89,6 +89,7 @@ public interface IInventoryService
|
||||
Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default);
|
||||
Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default);
|
||||
Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default);
|
||||
Task<IngredientDto?> AdjustAsync(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
@@ -205,6 +206,18 @@ public class InventoryService : IInventoryService
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct);
|
||||
if (entity is null) return false;
|
||||
|
||||
// Soft delete: Ingredient has a global DeletedAt query filter, so it (and its
|
||||
// recipe lines / stock movements) drop out of every query without FK trouble.
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IngredientDto?> AdjustAsync(
|
||||
string cafeId,
|
||||
string ingredientId,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IMediaStorageService
|
||||
@@ -37,11 +42,16 @@ public class MediaStorageService : IMediaStorageService
|
||||
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly ILogger<MediaStorageService> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public MediaStorageService(IWebHostEnvironment env, ILogger<MediaStorageService> logger)
|
||||
public MediaStorageService(
|
||||
IWebHostEnvironment env,
|
||||
ILogger<MediaStorageService> logger,
|
||||
IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_env = env;
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
}
|
||||
|
||||
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
|
||||
@@ -100,16 +110,29 @@ public class MediaStorageService : IMediaStorageService
|
||||
|| Model3dMime.Contains(file.ContentType);
|
||||
if (!isGlb) return null;
|
||||
|
||||
await using var buffer = new MemoryStream();
|
||||
await file.CopyToAsync(buffer, cancellationToken);
|
||||
var bytes = buffer.ToArray();
|
||||
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogInformation("Dedup hit for 3D model (cafe {CafeId}); reusing existing file", cafeId);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
|
||||
Directory.CreateDirectory(dir);
|
||||
var savedName = $"menu_3d_{Guid.NewGuid():N}.glb";
|
||||
var path = Path.Combine(dir, savedName);
|
||||
|
||||
await using var stream = File.Create(path);
|
||||
await file.CopyToAsync(stream, cancellationToken);
|
||||
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
||||
|
||||
var url = $"/uploads/{cafeId}/{savedName}";
|
||||
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, "menu_3d", file.FileName, cancellationToken);
|
||||
_logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId);
|
||||
return $"/uploads/{cafeId}/{savedName}";
|
||||
return url;
|
||||
}
|
||||
|
||||
private async Task<string?> SaveAsync(
|
||||
@@ -123,6 +146,20 @@ public class MediaStorageService : IMediaStorageService
|
||||
if (file.Length == 0 || file.Length > maxBytes) return null;
|
||||
if (!allowedMime.Contains(file.ContentType)) return null;
|
||||
|
||||
// Buffer once so we can hash the content and (if new) write it.
|
||||
await using var buffer = new MemoryStream();
|
||||
await file.CopyToAsync(buffer, cancellationToken);
|
||||
var bytes = buffer.ToArray();
|
||||
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
|
||||
// Dedup: an identical file already stored for this scope is reused as-is.
|
||||
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogInformation("Dedup hit for {Prefix} (cafe {CafeId}); reusing existing file", prefix, cafeId);
|
||||
return existing;
|
||||
}
|
||||
|
||||
var ext = file.ContentType.ToLowerInvariant() switch
|
||||
{
|
||||
"image/png" => ".png",
|
||||
@@ -138,10 +175,61 @@ public class MediaStorageService : IMediaStorageService
|
||||
var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}";
|
||||
var path = Path.Combine(dir, fileName);
|
||||
|
||||
await using var stream = File.Create(path);
|
||||
await file.CopyToAsync(stream, cancellationToken);
|
||||
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
||||
|
||||
var url = $"/uploads/{cafeId}/{fileName}";
|
||||
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, prefix, file.FileName, cancellationToken);
|
||||
_logger.LogInformation("Saved {Prefix} media for cafe {CafeId}", prefix, cafeId);
|
||||
return $"/uploads/{cafeId}/{fileName}";
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── Deduplication helpers ────────────────────────────────────────────────
|
||||
// MediaStorageService is a singleton; resolve a scoped DbContext per call.
|
||||
|
||||
private async Task<string?> FindExistingByHashAsync(string? cafeId, string hash, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
return await db.MediaAssets.AsNoTracking()
|
||||
.Where(m => m.CafeId == cafeId && m.ContentHash == hash)
|
||||
.Select(m => m.Url)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never let a dedup-lookup failure block an upload.
|
||||
_logger.LogWarning(ex, "Media dedup lookup failed; proceeding with a fresh upload");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecordAsync(
|
||||
string? cafeId, string hash, long size, string contentType,
|
||||
string url, string kind, string? originalName, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.MediaAssets.Add(new MediaAsset
|
||||
{
|
||||
CafeId = cafeId,
|
||||
ContentHash = hash,
|
||||
SizeBytes = size,
|
||||
ContentType = contentType,
|
||||
Url = url,
|
||||
Kind = kind,
|
||||
OriginalFileName = originalName,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The file is already written; a missing dedup record only means a
|
||||
// future identical upload won't be de-duplicated. Don't fail the upload.
|
||||
_logger.LogWarning(ex, "Failed to record media asset for cafe {CafeId}", cafeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ public class MenuAi3dGenerationService : IMenuAi3dGenerationService
|
||||
{
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, FeatureMenu3dAi, cancellationToken))
|
||||
return 0;
|
||||
return PlanLimits.MaxMenuAi3dPerMonth(planTier);
|
||||
return (await _catalog.GetLimitsAsync(planTier, cancellationToken)).MaxMenuAi3dPerMonth;
|
||||
}
|
||||
|
||||
private static string UsageKey(string cafeId) =>
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface IMenuService
|
||||
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||
Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||
Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class MenuService : IMenuService
|
||||
@@ -192,6 +193,16 @@ public class MenuService : IMenuService
|
||||
return ToItemDto(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.MenuItems.FirstOrDefaultAsync(i => i.Id == id && i.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return false;
|
||||
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalText(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
|
||||
@@ -67,6 +67,19 @@ public interface IOrderService
|
||||
RecordPaymentsRequest request,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<OrderDto> Items, int Total)> GetClosedOrdersAsync(
|
||||
string cafeId,
|
||||
DateOnly date,
|
||||
string? branchId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<OrderServiceResult<OrderDto>> CorrectPaymentsAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
CorrectPaymentsRequest request,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class OrderService : IOrderService
|
||||
@@ -1119,6 +1132,117 @@ public class OrderService : IOrderService
|
||||
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(true, dtos);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<OrderDto> Items, int Total)> GetClosedOrdersAsync(
|
||||
string cafeId,
|
||||
DateOnly date,
|
||||
string? branchId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (utcStart, utcEnd) = IranCalendar.GetUtcRangeForIranDay(date);
|
||||
|
||||
var query = _db.Orders
|
||||
.Where(o => o.CafeId == cafeId
|
||||
&& (o.Status == OrderStatus.Delivered || o.Status == OrderStatus.Cancelled)
|
||||
&& o.CreatedAt >= utcStart
|
||||
&& o.CreatedAt < utcEnd);
|
||||
|
||||
if (!string.IsNullOrEmpty(branchId))
|
||||
query = query.Where(o => o.BranchId == branchId);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
var orders = await ApplyOrderIncludes(query)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (orders.Select(MapOrder).ToList(), total);
|
||||
}
|
||||
|
||||
public async Task<OrderServiceResult<OrderDto>> CorrectPaymentsAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
CorrectPaymentsRequest request,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var order = await LoadOrderAsync(cafeId, orderId, cancellationToken);
|
||||
if (order is null)
|
||||
return new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_FOUND");
|
||||
|
||||
// Resolve the payments being voided — they must belong to this order and
|
||||
// still be live. Payments are never deleted; voiding marks them Refunded
|
||||
// so the original سند stays visible in history and audit.
|
||||
var toVoid = new List<Payment>();
|
||||
foreach (var paymentId in request.VoidPaymentIds.Distinct())
|
||||
{
|
||||
var payment = order.Payments.FirstOrDefault(p => p.Id == paymentId);
|
||||
if (payment is null)
|
||||
return new OrderServiceResult<OrderDto>(false, null, "PAYMENT_NOT_FOUND", "voidPaymentIds");
|
||||
if (payment.Status != PaymentStatus.Completed)
|
||||
return new OrderServiceResult<OrderDto>(false, null, "PAYMENT_ALREADY_REFUNDED", "voidPaymentIds");
|
||||
toVoid.Add(payment);
|
||||
}
|
||||
|
||||
var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken);
|
||||
if (string.IsNullOrEmpty(branchId))
|
||||
return new OrderServiceResult<OrderDto>(false, null, "NO_OPEN_SHIFT", "branchId");
|
||||
|
||||
// Corrections move money through the drawer, so they need an open shift
|
||||
// exactly like recording a payment does.
|
||||
var shiftCheck = await _shiftService.RequireOpenShiftForBranchAsync(cafeId, branchId, cancellationToken);
|
||||
if (!shiftCheck.Success)
|
||||
return new OrderServiceResult<OrderDto>(false, null, shiftCheck.ErrorCode, shiftCheck.Field);
|
||||
var openShift = shiftCheck.Data!;
|
||||
|
||||
foreach (var payment in toVoid)
|
||||
payment.Status = PaymentStatus.Refunded;
|
||||
|
||||
var replacements = request.Replacements.Select(p => new Payment
|
||||
{
|
||||
OrderId = orderId,
|
||||
Method = p.Method,
|
||||
Amount = p.Amount,
|
||||
Reference = p.Reference,
|
||||
Status = PaymentStatus.Completed
|
||||
}).ToList();
|
||||
_db.Payments.AddRange(replacements);
|
||||
|
||||
// Fully paid again after the correction → ensure the order is closed;
|
||||
// underpaid → leave the status alone (the remainder can be collected
|
||||
// through the normal payment flow later). EF navigation fixup may have
|
||||
// already appended the replacements to order.Payments, so exclude them
|
||||
// by reference to avoid double-counting.
|
||||
var paidTotal = order.Payments
|
||||
.Where(p => p.Status == PaymentStatus.Completed && !replacements.Contains(p))
|
||||
.Sum(p => p.Amount)
|
||||
+ replacements.Sum(p => p.Amount);
|
||||
if (paidTotal >= order.Total && OpenForPaymentStatuses.Contains(order.Status))
|
||||
order.Status = OrderStatus.Delivered;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var createdBy = userId ?? openShift.OpenedByUserId;
|
||||
foreach (var payment in toVoid)
|
||||
{
|
||||
await _shiftService.RecordTransactionAsync(
|
||||
cafeId, openShift.Id, CashTransactionType.Refund, payment.Method,
|
||||
payment.Amount, createdBy, orderId, request.Reason, cancellationToken);
|
||||
}
|
||||
foreach (var payment in replacements)
|
||||
{
|
||||
await _shiftService.RecordTransactionAsync(
|
||||
cafeId, openShift.Id, CashTransactionType.OrderPayment, payment.Method,
|
||||
payment.Amount, createdBy, orderId, request.Reason, cancellationToken);
|
||||
}
|
||||
|
||||
return new OrderServiceResult<OrderDto>(true, MapOrder(order));
|
||||
}
|
||||
|
||||
private static IQueryable<Order> ApplyOrderIncludes(IQueryable<Order> query) =>
|
||||
query
|
||||
.Include(o => o.Items)
|
||||
@@ -1221,5 +1345,6 @@ public class OrderService : IOrderService
|
||||
i.UnitPrice,
|
||||
i.Notes,
|
||||
i.IsVoided,
|
||||
i.VoidedAt)).ToList());
|
||||
i.VoidedAt)).ToList(),
|
||||
o.Source);
|
||||
}
|
||||
|
||||
@@ -99,27 +99,25 @@ public class PlanLimitChecker : IPlanLimitChecker
|
||||
return (false, "PLAN_LIMIT_REACHED", "Branch limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
|
||||
var smsCampaignPath = $"/api/cafes/{cafeId}/sms/campaign";
|
||||
if (path.Equals(smsCampaignPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{smsCampaignPath}/", StringComparison.OrdinalIgnoreCase))
|
||||
var tablesPath = $"/api/cafes/{cafeId}/tables";
|
||||
if (path.StartsWith(tablesPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
(path.Equals(tablesPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{tablesPath}/", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var limitsSms = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
|
||||
var maxSms = limitsSms.MaxSmsPerMonth;
|
||||
if (maxSms == 0)
|
||||
return (false, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan. Please upgrade.");
|
||||
|
||||
if (maxSms == int.MaxValue)
|
||||
return (true, null, null);
|
||||
|
||||
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
|
||||
var redis = _redis.GetDatabase();
|
||||
var used = await redis.StringGetAsync(monthKey);
|
||||
var usedCount = used.HasValue ? (int)used : 0;
|
||||
|
||||
if (usedCount >= maxSms)
|
||||
return (false, "PLAN_LIMIT_REACHED", "Monthly SMS limit reached for your plan. Please upgrade.");
|
||||
var limitsTables = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
|
||||
var maxTables = limitsTables.MaxTables;
|
||||
if (maxTables != int.MaxValue)
|
||||
{
|
||||
var tableCount = await _db.Tables.CountAsync(t => t.CafeId == cafeId, cancellationToken);
|
||||
if (tableCount >= maxTables)
|
||||
return (false, "PLAN_LIMIT_REACHED", "Table limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: SMS is deliberately NOT plan-gated — marketing SMS is
|
||||
// bring-your-own-provider (the café's own API key + sender line), so the
|
||||
// café's provider account is the only limit.
|
||||
|
||||
return (true, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ public class PublicService : IPublicService
|
||||
private readonly IBranchIdentityService _identity;
|
||||
private readonly IAbuseProtectionService _abuse;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
private readonly Meezi.Infrastructure.Services.Platform.IPlatformCatalogService _catalog;
|
||||
|
||||
public PublicService(
|
||||
AppDbContext db,
|
||||
@@ -62,7 +63,8 @@ public class PublicService : IPublicService
|
||||
IBranchMenuService branchMenu,
|
||||
IBranchIdentityService identity,
|
||||
IAbuseProtectionService abuse,
|
||||
IHttpContextAccessor http)
|
||||
IHttpContextAccessor http,
|
||||
Meezi.Infrastructure.Services.Platform.IPlatformCatalogService catalog)
|
||||
{
|
||||
_db = db;
|
||||
_orders = orders;
|
||||
@@ -72,8 +74,13 @@ public class PublicService : IPublicService
|
||||
_identity = identity;
|
||||
_abuse = abuse;
|
||||
_http = http;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
/// <summary>Free menus show a Meezi watermark; the `watermark_removed` feature (paid) hides it.</summary>
|
||||
private async Task<bool> ShowWatermarkAsync(Cafe cafe, CancellationToken ct) =>
|
||||
!await _catalog.IsFeatureEnabledForCafeAsync(cafe.Id, cafe.PlanTier, "watermark_removed", ct);
|
||||
|
||||
public Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
|
||||
DiscoverFilterParams filters,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
@@ -190,7 +197,8 @@ public class PublicService : IPublicService
|
||||
.Where(c => c.Items.Count > 0)
|
||||
.ToList();
|
||||
|
||||
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
|
||||
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped,
|
||||
await ShowWatermarkAsync(cafe, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<(GuestOrderPlacedDto? Data, string? ErrorCode, string? ErrorMessage)> PlaceOrderAsync(
|
||||
@@ -357,7 +365,8 @@ public class PublicService : IPublicService
|
||||
.OrderBy(c => categoryById.GetValueOrDefault(c.Id)?.SortOrder ?? 0)
|
||||
.ToList();
|
||||
|
||||
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
|
||||
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped,
|
||||
await ShowWatermarkAsync(cafe, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<(GuestQrOrderPlacedDto? Data, string? ErrorCode, string? Message)> PlaceBranchGuestOrderAsync(
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Report-history window checks. Takes the (admin-editable) max-history days
|
||||
/// directly so the limit comes from the plan catalog, not a hardcoded tier table.
|
||||
/// </summary>
|
||||
public static class ReportPlanGate
|
||||
{
|
||||
public static bool IsDateInRange(PlanTier tier, DateOnly date, DateOnly todayIran)
|
||||
public static bool IsDateInRange(int maxDays, DateOnly date, DateOnly todayIran)
|
||||
{
|
||||
var maxDays = PlanLimits.MaxReportHistoryDays(tier);
|
||||
if (maxDays == int.MaxValue)
|
||||
return date <= todayIran;
|
||||
|
||||
@@ -16,16 +16,15 @@ public static class ReportPlanGate
|
||||
}
|
||||
|
||||
public static (DateOnly From, DateOnly To)? ClampRange(
|
||||
PlanTier tier,
|
||||
int maxDays,
|
||||
DateOnly from,
|
||||
DateOnly to,
|
||||
DateOnly todayIran)
|
||||
{
|
||||
if (from > to) return null;
|
||||
if (!IsDateInRange(tier, to, todayIran) || !IsDateInRange(tier, from, todayIran))
|
||||
if (!IsDateInRange(maxDays, to, todayIran) || !IsDateInRange(maxDays, from, todayIran))
|
||||
return null;
|
||||
|
||||
var maxDays = PlanLimits.MaxReportHistoryDays(tier);
|
||||
if (maxDays == int.MaxValue)
|
||||
return (from, to);
|
||||
|
||||
@@ -36,16 +35,8 @@ public static class ReportPlanGate
|
||||
return (clampedFrom, clampedTo);
|
||||
}
|
||||
|
||||
public static string LimitMessage(PlanTier tier)
|
||||
{
|
||||
var days = PlanLimits.MaxReportHistoryDays(tier);
|
||||
return tier switch
|
||||
{
|
||||
PlanTier.Free =>
|
||||
"Daily reports on the Free plan are limited to today and the previous 7 days. Upgrade to Pro for 90 days of history.",
|
||||
PlanTier.Pro =>
|
||||
"Daily reports on the Pro plan are limited to the last 90 days. Upgrade to Business for unlimited history.",
|
||||
_ => "Report date is outside your plan range."
|
||||
};
|
||||
}
|
||||
public static string LimitMessage(int maxDays) =>
|
||||
maxDays == int.MaxValue
|
||||
? "Report date is outside the allowed range."
|
||||
: $"Daily reports on your plan are limited to the last {maxDays} days. Upgrade for more history.";
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ public interface IReservationService
|
||||
string reservationId,
|
||||
ReservationStatus status,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(
|
||||
string cafeId,
|
||||
string reservationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class ReservationService : IReservationService
|
||||
@@ -118,6 +123,25 @@ public class ReservationService : IReservationService
|
||||
return Map(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(
|
||||
string cafeId,
|
||||
string reservationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.TableReservations
|
||||
.FirstOrDefaultAsync(r => r.Id == reservationId && r.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return false;
|
||||
|
||||
// Soft delete: TableReservation has a global DeletedAt query filter.
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
if (!string.IsNullOrEmpty(entity.TableId))
|
||||
await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static ReservationDto Map(TableReservation r) => new(
|
||||
r.Id,
|
||||
r.CafeId,
|
||||
|
||||
@@ -62,7 +62,7 @@ public class ReviewService : IReviewService
|
||||
DiscoverFilterParams filters,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null);
|
||||
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null && c.ShowOnKoja);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filters.City))
|
||||
query = query.Where(c => c.City != null && c.City.Contains(filters.City));
|
||||
|
||||
@@ -228,11 +228,16 @@ public class ShiftService : IShiftService
|
||||
.Where(t => t.Type == CashTransactionType.OrderPayment && t.Method == PaymentMethod.Cash)
|
||||
.Sum(t => t.Amount);
|
||||
|
||||
// Payment corrections (اصلاح سند) refund cash back out of the drawer.
|
||||
var cashRefunds = transactions
|
||||
.Where(t => t.Type == CashTransactionType.Refund && t.Method == PaymentMethod.Cash)
|
||||
.Sum(t => t.Amount);
|
||||
|
||||
var withdrawals = transactions
|
||||
.Where(t => t.Type == CashTransactionType.Withdrawal)
|
||||
.Sum(t => t.Amount);
|
||||
|
||||
return openingCash + cashPayments - withdrawals;
|
||||
return openingCash + cashPayments - cashRefunds - withdrawals;
|
||||
}
|
||||
|
||||
private static ShiftDto ToDto(Shift s) => new(
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
@@ -11,14 +9,25 @@ namespace Meezi.API.Services;
|
||||
|
||||
public interface ISmsMarketingService
|
||||
{
|
||||
Task<SmsUsageDto> GetUsageAsync(string cafeId, PlanTier planTier, CancellationToken cancellationToken = default);
|
||||
Task<SmsUsageDto> GetUsageAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<SmsSettingsDto> GetSettingsAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<(bool Success, SmsSettingsDto? Data, string? ErrorCode, string? Message)> UpdateSettingsAsync(
|
||||
string cafeId,
|
||||
UpdateSmsSettingsRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<SmsBalanceDto> GetBalanceAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
SendSmsCampaignRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marketing SMS is bring-your-own-provider: each café configures its OWN
|
||||
/// Kavenegar API key + sender line and pays its provider directly. The platform
|
||||
/// neither sells SMS nor meters it against plan limits; the monthly counter is
|
||||
/// informational only. (Login OTPs still go through the platform account.)
|
||||
/// </summary>
|
||||
public class SmsMarketingService : ISmsMarketingService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
@@ -35,40 +44,111 @@ public class SmsMarketingService : ISmsMarketingService
|
||||
_redis = redis;
|
||||
}
|
||||
|
||||
public async Task<SmsUsageDto> GetUsageAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
CancellationToken cancellationToken = default)
|
||||
public async Task<SmsUsageDto> GetUsageAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var month = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
var used = await GetUsedCountAsync(cafeId, month);
|
||||
var limit = PlanLimits.MaxSmsPerMonth(planTier);
|
||||
return new SmsUsageDto(used, limit == int.MaxValue ? -1 : limit, month);
|
||||
// -1 = no platform limit; the café's own provider account is the only cap.
|
||||
return new SmsUsageDto(used, -1, month);
|
||||
}
|
||||
|
||||
public async Task<SmsSettingsDto> GetSettingsAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.AsNoTracking()
|
||||
.Where(c => c.Id == cafeId)
|
||||
.Select(c => new { c.SmsApiKey, c.SmsSenderNumber })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (cafe is null || string.IsNullOrWhiteSpace(cafe.SmsApiKey))
|
||||
return new SmsSettingsDto(false, null, cafe?.SmsSenderNumber);
|
||||
|
||||
return new SmsSettingsDto(true, MaskApiKey(cafe.SmsApiKey), cafe.SmsSenderNumber);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SmsSettingsDto? Data, string? ErrorCode, string? Message)> UpdateSettingsAsync(
|
||||
string cafeId,
|
||||
UpdateSmsSettingsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null)
|
||||
return (false, null, "NOT_FOUND", "Cafe not found.");
|
||||
|
||||
var apiKey = request.ApiKey?.Trim();
|
||||
var sender = request.SenderNumber?.Trim();
|
||||
|
||||
// Empty strings clear the configuration (turn SMS off for this café).
|
||||
if (string.IsNullOrEmpty(apiKey) && string.IsNullOrEmpty(sender))
|
||||
{
|
||||
cafe.SmsApiKey = null;
|
||||
cafe.SmsSenderNumber = null;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return (true, new SmsSettingsDto(false, null, null), null, null);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey) && string.IsNullOrEmpty(cafe.SmsApiKey))
|
||||
return (false, null, "VALIDATION_ERROR", "API key is required.");
|
||||
if (string.IsNullOrEmpty(sender))
|
||||
return (false, null, "VALIDATION_ERROR", "Sender number is required.");
|
||||
|
||||
// A new key was provided — verify it against the provider before saving so
|
||||
// the owner gets immediate feedback on a typo'd key.
|
||||
if (!string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
var info = await _smsService.GetAccountInfoAsync(apiKey, cancellationToken);
|
||||
if (info is null)
|
||||
return (false, null, "SMS_KEY_INVALID", "The API key was rejected by the SMS provider.");
|
||||
cafe.SmsApiKey = apiKey;
|
||||
}
|
||||
|
||||
cafe.SmsSenderNumber = sender;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (true, new SmsSettingsDto(true, MaskApiKey(cafe.SmsApiKey!), cafe.SmsSenderNumber), null, null);
|
||||
}
|
||||
|
||||
public async Task<SmsBalanceDto> GetBalanceAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKey = await _db.Cafes.AsNoTracking()
|
||||
.Where(c => c.Id == cafeId)
|
||||
.Select(c => c.SmsApiKey)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
return new SmsBalanceDto(0, "master", false);
|
||||
|
||||
var info = await _smsService.GetAccountInfoAsync(apiKey, cancellationToken);
|
||||
return info is not null
|
||||
? new SmsBalanceDto(info.RemainCredit, info.AccountType, true)
|
||||
: new SmsBalanceDto(0, "master", false);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
SendSmsCampaignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var maxSms = PlanLimits.MaxSmsPerMonth(planTier);
|
||||
if (maxSms == 0)
|
||||
return (false, null, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan.");
|
||||
var cafe = await _db.Cafes.AsNoTracking()
|
||||
.Where(c => c.Id == cafeId)
|
||||
.Select(c => new { c.SmsApiKey, c.SmsSenderNumber })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (cafe is null || string.IsNullOrWhiteSpace(cafe.SmsApiKey) || string.IsNullOrWhiteSpace(cafe.SmsSenderNumber))
|
||||
return (false, null, "SMS_NOT_CONFIGURED",
|
||||
"Configure your own SMS provider (API key + sender line) in the SMS settings first.");
|
||||
|
||||
var phones = await ResolvePhonesAsync(cafeId, request, cancellationToken);
|
||||
if (phones.Count == 0)
|
||||
return (false, null, "NOT_FOUND", "No recipients found.");
|
||||
|
||||
var month = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
var used = await GetUsedCountAsync(cafeId, month);
|
||||
if (maxSms != int.MaxValue && used + phones.Count > maxSms)
|
||||
return (false, null, "PLAN_LIMIT_REACHED", "Monthly SMS limit would be exceeded.");
|
||||
|
||||
var result = await _smsService.SendBulkAsync(phones, request.Message, cancellationToken);
|
||||
var result = await _smsService.SendBulkWithCredentialsAsync(
|
||||
cafe.SmsApiKey, cafe.SmsSenderNumber, phones, request.Message, cancellationToken);
|
||||
|
||||
if (result.SentCount > 0)
|
||||
{
|
||||
var month = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
await IncrementUsageAsync(cafeId, month, result.SentCount);
|
||||
}
|
||||
|
||||
return (true, new SmsCampaignResult(result.SentCount, result.FailedCount), null, null);
|
||||
}
|
||||
@@ -94,6 +174,9 @@ public class SmsMarketingService : ISmsMarketingService
|
||||
return await query.Select(c => c.Phone).Distinct().ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string MaskApiKey(string apiKey) =>
|
||||
apiKey.Length <= 4 ? "****" : $"****{apiKey[^4..]}";
|
||||
|
||||
private async Task<int> GetUsedCountAsync(string cafeId, string month)
|
||||
{
|
||||
var redis = _redis.GetDatabase();
|
||||
|
||||
@@ -183,5 +183,6 @@ public class SnappfoodWebhookService : ISnappfoodWebhookService
|
||||
i.UnitPrice,
|
||||
i.Notes,
|
||||
i.IsVoided,
|
||||
i.VoidedAt)).ToList());
|
||||
i.VoidedAt)).ToList(),
|
||||
o.Source);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,15 @@ public class TerminalRegistryService : ITerminalRegistryService
|
||||
{
|
||||
private static readonly TimeSpan TerminalTtl = TimeSpan.FromDays(90);
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly Meezi.Infrastructure.Services.Platform.IPlatformCatalogService _catalog;
|
||||
|
||||
public TerminalRegistryService(IConnectionMultiplexer redis) => _redis = redis;
|
||||
public TerminalRegistryService(
|
||||
IConnectionMultiplexer redis,
|
||||
Meezi.Infrastructure.Services.Platform.IPlatformCatalogService catalog)
|
||||
{
|
||||
_redis = redis;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
public async Task<(bool Allowed, string? ErrorCode, string? Message)> RegisterAsync(
|
||||
string cafeId,
|
||||
@@ -38,7 +45,7 @@ public class TerminalRegistryService : ITerminalRegistryService
|
||||
terminalId = terminalId.Trim();
|
||||
var db = _redis.GetDatabase();
|
||||
var setKey = $"terminals:{cafeId}";
|
||||
var max = PlanLimits.MaxTerminals(tier);
|
||||
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxTerminals;
|
||||
|
||||
if (max == int.MaxValue)
|
||||
{
|
||||
|
||||
@@ -139,6 +139,22 @@ public class RecordPaymentsRequestValidator : AbstractValidator<RecordPaymentsRe
|
||||
}
|
||||
}
|
||||
|
||||
public class CorrectPaymentsRequestValidator : AbstractValidator<CorrectPaymentsRequest>
|
||||
{
|
||||
public CorrectPaymentsRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).NotEmpty().MinimumLength(3).MaximumLength(500);
|
||||
RuleFor(x => x)
|
||||
.Must(x => (x.VoidPaymentIds?.Count ?? 0) > 0 || (x.Replacements?.Count ?? 0) > 0)
|
||||
.WithMessage("At least one payment to void or one replacement is required.");
|
||||
RuleForEach(x => x.Replacements).ChildRules(p =>
|
||||
{
|
||||
p.RuleFor(x => x.Method).IsInEnum();
|
||||
p.RuleFor(x => x.Amount).GreaterThan(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class AppendOrderItemsRequestValidator : AbstractValidator<AppendOrderItemsRequest>
|
||||
{
|
||||
public AppendOrderItemsRequestValidator()
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"Key": "meezi-dev-secret-key-min-32-chars!!",
|
||||
"Issuer": "meezi",
|
||||
"Audience": "meezi",
|
||||
"AccessTokenExpiryDays": 7,
|
||||
"RefreshTokenExpiryDays": 30
|
||||
"AccessTokenExpiryDays": 30,
|
||||
"RefreshTokenExpiryDays": 365
|
||||
},
|
||||
"App": {
|
||||
"PublicBaseUrl": "https://localhost:7208",
|
||||
|
||||
@@ -45,6 +45,36 @@ public class AdminCafesController : AdminApiControllerBase
|
||||
return Ok(new ApiResponse<object>(true, new { cafeId, request.FeatureKey, request.IsEnabled }));
|
||||
}
|
||||
|
||||
/// <summary>Generate (or regenerate) a permanent recovery key for the café's
|
||||
/// Owner. The raw key is returned ONCE — only its hash is stored.</summary>
|
||||
[HttpPost("{cafeId}/recovery-key")]
|
||||
public async Task<IActionResult> GenerateRecoveryKey(string cafeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var (ok, key, code) = await _platform.GenerateRecoveryKeyAsync(cafeId, cancellationToken);
|
||||
if (!ok)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"NO_OWNER" => BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("NO_OWNER", "This café has no owner account to attach a recovery key to."))),
|
||||
_ => NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")))
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<object>(true, new { cafeId, key }));
|
||||
}
|
||||
|
||||
/// <summary>Revoke the café's recovery key (clears the stored hash).</summary>
|
||||
[HttpDelete("{cafeId}/recovery-key")]
|
||||
public async Task<IActionResult> RevokeRecoveryKey(string cafeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var ok = await _platform.RevokeRecoveryKeyAsync(cafeId, cancellationToken);
|
||||
if (!ok)
|
||||
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found.")));
|
||||
|
||||
return Ok(new ApiResponse<object>(true, new { cafeId }));
|
||||
}
|
||||
|
||||
[HttpGet("{cafeId}/discover-profile")]
|
||||
public async Task<IActionResult> GetDiscoverProfile(string cafeId, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||