Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cc1c3a423 | |||
| b0896dc777 | |||
| f368765419 | |||
| 197f6f2d38 | |||
| 7d5af0c81b | |||
| 9e47a4e60c | |||
| cb57c61a11 | |||
| 67450393fc | |||
| ae5c750d34 | |||
| f985deb233 | |||
| 27ca80fd54 | |||
| b162335b48 | |||
| 27b3ac60c7 | |||
| aede5bfd97 | |||
| eaf911e12c | |||
| 166f2b2586 | |||
| 8ea98bdc09 | |||
| 72abf05a5f | |||
| 63e3cb6962 | |||
| c360fbb068 | |||
| 1264606410 | |||
| cad5ba6ea3 | |||
| 5596e8dbc5 | |||
| 46f962eb75 | |||
| 6184c83fa7 | |||
| 0c2ded4070 | |||
| 2a24798a59 | |||
| 6d71770f2e | |||
| fd1f985597 | |||
| d261c13175 | |||
| 958addf734 | |||
| 8703e9cf87 | |||
| fb6a20eaa1 | |||
| 97bd63015f | |||
| 3dfcb1585b | |||
| 2cff5051ac | |||
| 53d90fa357 | |||
| 7a5ea75b50 | |||
| 236013f53c | |||
| 170a9aa7ac | |||
| 149a4d88cd | |||
| aebfa825cd | |||
| 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",
|
"runtimeExecutable": "dotnet",
|
||||||
"runtimeArgs": ["run", "--project", "F:/Projects/DrSousan/DrSousan.Api", "--urls", "http://localhost:5000"],
|
"runtimeArgs": ["run", "--project", "F:/Projects/DrSousan/DrSousan.Api", "--urls", "http://localhost:5000"],
|
||||||
"port": 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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ JWT_KEY=change-me-64-char-random-string-use-openssl-rand-hex-32-output
|
|||||||
|
|
||||||
NEXT_PUBLIC_API_URL=http://171.22.25.73:5080
|
NEXT_PUBLIC_API_URL=http://171.22.25.73:5080
|
||||||
NEXT_PUBLIC_ADMIN_API_URL=http://171.22.25.73:5081
|
NEXT_PUBLIC_ADMIN_API_URL=http://171.22.25.73:5081
|
||||||
NEXT_PUBLIC_SITE_URL=http://171.22.25.73:3010
|
# Public site origin — MUST be the real domain in prod (used for canonical URLs,
|
||||||
NEXT_PUBLIC_KOJA_URL=http://171.22.25.73:3103
|
# sitemap, robots, OG tags). A wrong value here de-indexes the whole site in GSC.
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://meezi.ir
|
||||||
|
NEXT_PUBLIC_KOJA_URL=https://koja.meezi.ir
|
||||||
|
|
||||||
APP_QR_BASE_URL=http://171.22.25.73:3101
|
APP_QR_BASE_URL=http://171.22.25.73:3101
|
||||||
BILLING_DASHBOARD_URL=http://171.22.25.73:3101
|
BILLING_DASHBOARD_URL=http://171.22.25.73:3101
|
||||||
@@ -81,6 +83,14 @@ SEED_ADMIN_PASSWORD=change-me-strong-admin-password
|
|||||||
ZARINPAL_MERCHANT_ID=
|
ZARINPAL_MERCHANT_ID=
|
||||||
ZARINPAL_SANDBOX=false
|
ZARINPAL_SANDBOX=false
|
||||||
|
|
||||||
|
# ── Payment: FlatRender Pay (ZarinPal broker) ─────────────────────────────────
|
||||||
|
# Broker keys from the FlatRender dashboard. Webhook is registered at the broker as
|
||||||
|
# https://api.meezi.ir/api/payment/webhook. Keep the live secret OUT of git.
|
||||||
|
FLATPAY_API_KEY=
|
||||||
|
FLATPAY_SECRET=
|
||||||
|
FLATPAY_BASE_URL=https://pay.flatrender.ir
|
||||||
|
FLATPAY_RETURN_URL=https://meezi.ir/payment/return
|
||||||
|
|
||||||
# ── SMS: Kavenegar ────────────────────────────────────────────────────────────
|
# ── SMS: Kavenegar ────────────────────────────────────────────────────────────
|
||||||
# Empty = OTP is logged to API console (fine for dev, not for production)
|
# Empty = OTP is logged to API console (fine for dev, not for production)
|
||||||
KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D
|
KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D
|
||||||
|
|||||||
@@ -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>
|
</configuration>
|
||||||
EOF
|
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
|
- name: Restore
|
||||||
run: dotnet restore src/Meezi.API/Meezi.API.csproj --configfile /tmp/nuget.ci.config
|
run: dotnet restore src/Meezi.API/Meezi.API.csproj --configfile /tmp/nuget.ci.config
|
||||||
env:
|
env:
|
||||||
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
||||||
|
NUGET_CERT_REVOCATION_MODE: offline
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build src/Meezi.API/Meezi.API.csproj --no-restore -c Release
|
run: dotnet build src/Meezi.API/Meezi.API.csproj --no-restore -c Release
|
||||||
@@ -128,10 +148,23 @@ jobs:
|
|||||||
</configuration>
|
</configuration>
|
||||||
EOF
|
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
|
- name: Restore
|
||||||
run: dotnet restore src/Meezi.Admin.API/Meezi.Admin.API.csproj --configfile /tmp/nuget.ci.config
|
run: dotnet restore src/Meezi.Admin.API/Meezi.Admin.API.csproj --configfile /tmp/nuget.ci.config
|
||||||
env:
|
env:
|
||||||
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
||||||
|
NUGET_CERT_REVOCATION_MODE: offline
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build src/Meezi.Admin.API/Meezi.Admin.API.csproj --no-restore -c Release
|
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 \
|
-f docker-compose.admin.yml \
|
||||||
up -d --no-deps admin-web
|
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
|
- name: Show all running containers
|
||||||
if: always()
|
if: always()
|
||||||
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
|
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
# Domains needed in DNS (all → same server IP):
|
# Domains needed in DNS (all → same server IP):
|
||||||
# meezi.ir, app.meezi.ir, api.meezi.ir,
|
# meezi.ir, app.meezi.ir, api.meezi.ir,
|
||||||
# koja.meezi.ir, admin.meezi.ir, admin-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}
|
email {$ACME_EMAIL}
|
||||||
@@ -41,3 +42,10 @@ admin.{$DOMAIN} {
|
|||||||
admin-api.{$DOMAIN} {
|
admin-api.{$DOMAIN} {
|
||||||
reverse_proxy admin-api:8080
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Meezi.PrintAgent;
|
||||||
|
|
||||||
|
/// <summary>Persisted agent identity — written to %APPDATA%\MeeziPrintAgent\config.json.</summary>
|
||||||
|
public class AgentConfig
|
||||||
|
{
|
||||||
|
/// <summary>Origin of the Meezi API, e.g. https://app.meezi.ir.</summary>
|
||||||
|
public string? ApiBaseUrl { get; set; }
|
||||||
|
public string? Token { get; set; }
|
||||||
|
public string? CafeId { get; set; }
|
||||||
|
public string? AgentId { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
private static string Dir =>
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeeziPrintAgent");
|
||||||
|
private static string FilePath => Path.Combine(Dir, "config.json");
|
||||||
|
|
||||||
|
public bool IsPaired => !string.IsNullOrWhiteSpace(Token) && !string.IsNullOrWhiteSpace(ApiBaseUrl);
|
||||||
|
|
||||||
|
public static AgentConfig Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(FilePath))
|
||||||
|
return JsonSerializer.Deserialize<AgentConfig>(File.ReadAllText(FilePath)) ?? new AgentConfig();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// corrupt/unreadable config → start fresh
|
||||||
|
}
|
||||||
|
return new AgentConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Dir);
|
||||||
|
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<!-- Windows-only: uses winspool (raw printing) + WMI (printer discovery).
|
||||||
|
Overrides the repo-wide net10.0 / central package management on purpose so
|
||||||
|
this app stays independent of the API build (CI never compiles it). -->
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>Meezi.PrintAgent</RootNamespace>
|
||||||
|
<AssemblyName>MeeziPrintAgent</AssemblyName>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
|
||||||
|
<PackageReference Include="System.Management" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace Meezi.PrintAgent;
|
||||||
|
|
||||||
|
/// <summary>A host on the café LAN answering on a probed port. Property names match
|
||||||
|
/// the cloud's <c>DiscoveredDevice</c> record so SignalR maps them across.</summary>
|
||||||
|
public record ScannedDevice(string Ip, int Port, string Kind);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scans the agent PC's local /24 subnet(s) for hosts answering on the given TCP
|
||||||
|
/// ports — used to auto-find network printers (:9100) and card terminals (:8088)
|
||||||
|
/// so the café owner doesn't have to type IP addresses.
|
||||||
|
/// </summary>
|
||||||
|
public static class NetworkScanner
|
||||||
|
{
|
||||||
|
private const int MaxConcurrency = 128;
|
||||||
|
private const int ConnectTimeoutMs = 300;
|
||||||
|
|
||||||
|
public static async Task<List<ScannedDevice>> ScanAsync(string portsCsv, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ports = portsCsv
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(p => int.TryParse(p, out var n) ? n : 0)
|
||||||
|
.Where(n => n is > 0 and <= 65535)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (ports.Count == 0) ports = [9100, 8088];
|
||||||
|
|
||||||
|
var results = new ConcurrentBag<ScannedDevice>();
|
||||||
|
using var gate = new SemaphoreSlim(MaxConcurrency);
|
||||||
|
var tasks = new List<Task>();
|
||||||
|
|
||||||
|
foreach (var prefix in LocalSubnets())
|
||||||
|
{
|
||||||
|
for (var host = 1; host <= 254; host++)
|
||||||
|
{
|
||||||
|
var ip = $"{prefix}.{host}";
|
||||||
|
foreach (var port in ports)
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(ct);
|
||||||
|
tasks.Add(Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (await CanConnectAsync(ip, port))
|
||||||
|
results.Add(new ScannedDevice(ip, port, Classify(port)));
|
||||||
|
}
|
||||||
|
finally { gate.Release(); }
|
||||||
|
}, ct));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
return results
|
||||||
|
.DistinctBy(d => $"{d.Ip}:{d.Port}")
|
||||||
|
.OrderBy(d => d.Ip)
|
||||||
|
.ThenBy(d => d.Port)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Distinct /24 prefixes of this PC's up, non-loopback IPv4 interfaces.</summary>
|
||||||
|
private static IEnumerable<string> LocalSubnets()
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>();
|
||||||
|
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||||
|
{
|
||||||
|
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||||
|
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
||||||
|
{
|
||||||
|
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||||
|
if (IPAddress.IsLoopback(ua.Address)) continue;
|
||||||
|
var b = ua.Address.GetAddressBytes();
|
||||||
|
var prefix = $"{b[0]}.{b[1]}.{b[2]}";
|
||||||
|
if (seen.Add(prefix)) yield return prefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> CanConnectAsync(string ip, int port)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
var connect = client.ConnectAsync(ip, port);
|
||||||
|
var done = await Task.WhenAny(connect, Task.Delay(ConnectTimeoutMs));
|
||||||
|
if (done != connect) return false;
|
||||||
|
await connect; // observe exceptions
|
||||||
|
return client.Connected;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Classify(int port) => port switch
|
||||||
|
{
|
||||||
|
9100 => "network-printer",
|
||||||
|
8088 => "pos-terminal",
|
||||||
|
_ => "other",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace Meezi.PrintAgent;
|
||||||
|
|
||||||
|
/// <summary>Redeems a one-time pairing code for a long-lived agent token.</summary>
|
||||||
|
public static class Pairing
|
||||||
|
{
|
||||||
|
private record ClaimReq(string code, string? name, string? machineName);
|
||||||
|
private record ApiEnvelope<T>(bool success, T? data);
|
||||||
|
private record ClaimData(string agentId, string token, string cafeId, string agentName);
|
||||||
|
|
||||||
|
public static async Task<AgentConfig?> ClaimAsync(string apiBaseUrl, string code, string name)
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { BaseAddress = new Uri(apiBaseUrl), Timeout = TimeSpan.FromSeconds(20) };
|
||||||
|
HttpResponseMessage resp;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
resp = await http.PostAsJsonAsync("/api/print-agent/claim",
|
||||||
|
new ClaimReq(code, name, Environment.MachineName));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" network error: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var env = await resp.Content.ReadFromJsonAsync<ApiEnvelope<ClaimData>>();
|
||||||
|
if (env?.success != true || env.data is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new AgentConfig
|
||||||
|
{
|
||||||
|
ApiBaseUrl = apiBaseUrl.TrimEnd('/'),
|
||||||
|
Token = env.data.token,
|
||||||
|
CafeId = env.data.cafeId,
|
||||||
|
AgentId = env.data.agentId,
|
||||||
|
Name = env.data.agentName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace Meezi.PrintAgent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relays a card-terminal payment on the café LAN. The cloud can't reach the
|
||||||
|
/// terminal's private IP, so it hands the agent the amount and the terminal's
|
||||||
|
/// ip:port; the agent POSTs to the terminal's local HTTP <c>/pay</c> endpoint and
|
||||||
|
/// reports back whether it was approved.
|
||||||
|
/// </summary>
|
||||||
|
public static class PosTerminal
|
||||||
|
{
|
||||||
|
// A card payment blocks on the customer inserting/approving — allow plenty.
|
||||||
|
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(90) };
|
||||||
|
|
||||||
|
public static async Task<(bool Ok, string? Error)> SendPaymentAsync(
|
||||||
|
string ip, int port, long amount, string orderId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var url = $"http://{ip}:{port}/pay";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var resp = await Http.PostAsJsonAsync(url, new { amount, orderId }, ct);
|
||||||
|
if (resp.IsSuccessStatusCode) return (true, null);
|
||||||
|
return (false, $"POS_DEVICE_REJECTED:HTTP {(int)resp.StatusCode}");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
return (false, "POS_DEVICE_TIMEOUT");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (false, $"POS_DEVICE_CONNECTION_FAILED:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Management;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
namespace Meezi.PrintAgent;
|
||||||
|
|
||||||
|
/// <summary>One printer the agent can reach. SystemName is what it prints to (the
|
||||||
|
/// Windows printer name, or "ip:port" for a raw network device).</summary>
|
||||||
|
public record DiscoveredPrinter(string SystemName, string DisplayName, string Kind);
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public static class PrinterDiscovery
|
||||||
|
{
|
||||||
|
/// <summary>Every printer installed on this PC (USB and network-with-driver alike).</summary>
|
||||||
|
public static List<DiscoveredPrinter> Discover()
|
||||||
|
{
|
||||||
|
var list = new List<DiscoveredPrinter>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var searcher = new ManagementObjectSearcher(
|
||||||
|
"SELECT Name, PortName, Network FROM Win32_Printer");
|
||||||
|
foreach (var o in searcher.Get())
|
||||||
|
{
|
||||||
|
using var p = (ManagementObject)o;
|
||||||
|
var name = p["Name"]?.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||||
|
var port = p["PortName"]?.ToString() ?? "";
|
||||||
|
var network = p["Network"] as bool? ?? false;
|
||||||
|
list.Add(new DiscoveredPrinter(name!, name!, ClassifyKind(port, network)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// WMI unavailable — report nothing rather than crash.
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ClassifyKind(string port, bool network)
|
||||||
|
{
|
||||||
|
var up = port.ToUpperInvariant();
|
||||||
|
if (up.StartsWith("USB") || up.StartsWith("DOT4")) return "usb";
|
||||||
|
if (network || up.StartsWith("IP_") || up.StartsWith("WSD") || up.Contains(':')) return "network";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using Meezi.PrintAgent;
|
||||||
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
|
||||||
|
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||||
|
Console.WriteLine("=== Meezi Print Agent (پرینتسرور میزی) ===");
|
||||||
|
|
||||||
|
var config = AgentConfig.Load();
|
||||||
|
var wantsPair = args.Length > 0 && args[0].Equals("pair", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!config.IsPaired || wantsPair)
|
||||||
|
{
|
||||||
|
var paired = await PairInteractiveAsync(config);
|
||||||
|
if (paired is null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Pairing cancelled or failed.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
paired.Save();
|
||||||
|
config = paired;
|
||||||
|
Console.WriteLine($"✓ Paired as '{config.Name}'. Configuration saved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await RunAsync(config);
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
static async Task<AgentConfig?> PairInteractiveAsync(AgentConfig existing)
|
||||||
|
{
|
||||||
|
var defaultUrl = existing.ApiBaseUrl ?? "https://app.meezi.ir";
|
||||||
|
Console.Write($"Meezi API URL [{defaultUrl}]: ");
|
||||||
|
var url = Console.ReadLine();
|
||||||
|
if (string.IsNullOrWhiteSpace(url)) url = defaultUrl;
|
||||||
|
|
||||||
|
Console.Write("Pairing code (from Dashboard → Settings → Printers): ");
|
||||||
|
var code = Console.ReadLine()?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(code)) return null;
|
||||||
|
|
||||||
|
Console.Write($"Name for this PC [{Environment.MachineName}]: ");
|
||||||
|
var name = Console.ReadLine();
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) name = Environment.MachineName;
|
||||||
|
|
||||||
|
Console.WriteLine("Pairing…");
|
||||||
|
var cfg = await Pairing.ClaimAsync(url!, code!, name!);
|
||||||
|
if (cfg is null) Console.WriteLine(" Invalid/expired code, or the URL is wrong.");
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task RunAsync(AgentConfig config)
|
||||||
|
{
|
||||||
|
var hubUrl = $"{config.ApiBaseUrl!.TrimEnd('/')}/hubs/print-agent" +
|
||||||
|
$"?access_token={Uri.EscapeDataString(config.Token!)}";
|
||||||
|
|
||||||
|
var connection = new HubConnectionBuilder()
|
||||||
|
.WithUrl(hubUrl)
|
||||||
|
.WithAutomaticReconnect(new ForeverRetry())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
connection.On<string, string, string>("PrintJob", async (jobId, printerSystemName, base64) =>
|
||||||
|
{
|
||||||
|
var ok = false;
|
||||||
|
string? err = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = Convert.FromBase64String(base64);
|
||||||
|
await RawPrinter.PrintAsync(printerSystemName, data, CancellationToken.None);
|
||||||
|
ok = true;
|
||||||
|
Console.WriteLine($"[print] {data.Length} bytes → {printerSystemName} ✓");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
err = ex.Message;
|
||||||
|
Console.WriteLine($"[print] {printerSystemName} ✗ {ex.Message}");
|
||||||
|
}
|
||||||
|
try { await connection.InvokeAsync("JobResult", jobId, ok, err); } catch { /* ack best-effort */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cloud → agent: relay a card-terminal payment to the terminal on the LAN.
|
||||||
|
connection.On<string, string, int, long, string>("PaymentRequest", async (requestId, ip, port, amount, orderId) =>
|
||||||
|
{
|
||||||
|
var (ok, err) = await PosTerminal.SendPaymentAsync(ip, port, amount, orderId, CancellationToken.None);
|
||||||
|
Console.WriteLine(ok
|
||||||
|
? $"[pay] {amount} → {ip}:{port} ✓"
|
||||||
|
: $"[pay] {ip}:{port} ✗ {err}");
|
||||||
|
try { await connection.InvokeAsync("PaymentResult", requestId, ok, err); } catch { /* ack best-effort */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cloud → agent: scan the LAN for hosts on the given ports (printers :9100, terminals :8088).
|
||||||
|
connection.On<string, string>("ScanNetwork", async (requestId, ports) =>
|
||||||
|
{
|
||||||
|
List<ScannedDevice> found;
|
||||||
|
try { found = await NetworkScanner.ScanAsync(ports, CancellationToken.None); }
|
||||||
|
catch (Exception ex) { Console.WriteLine($"[scan] failed: {ex.Message}"); found = []; }
|
||||||
|
Console.WriteLine($"[scan] ports={ports} → {found.Count} host(s): " +
|
||||||
|
string.Join(", ", found.Select(d => $"{d.Ip}:{d.Port}")));
|
||||||
|
try { await connection.InvokeAsync("ReportScan", requestId, found); } catch { /* best-effort */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.Reconnected += async _ =>
|
||||||
|
{
|
||||||
|
Console.WriteLine("[hub] reconnected");
|
||||||
|
await SafeReportAsync(connection);
|
||||||
|
};
|
||||||
|
connection.Closed += _ =>
|
||||||
|
{
|
||||||
|
Console.WriteLine("[hub] connection closed");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await ConnectWithRetryAsync(connection);
|
||||||
|
Console.WriteLine("[hub] connected");
|
||||||
|
await SafeReportAsync(connection);
|
||||||
|
|
||||||
|
// Heartbeat + re-report every 2 minutes (printers added/removed get picked up).
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(2));
|
||||||
|
while (await timer.WaitForNextTickAsync())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.InvokeAsync("Heartbeat");
|
||||||
|
await SafeReportAsync(connection);
|
||||||
|
}
|
||||||
|
catch { /* will recover on reconnect */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Console.WriteLine("Agent running. Leave this window open. Press Ctrl+C to quit.");
|
||||||
|
await Task.Delay(Timeout.Infinite);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task SafeReportAsync(HubConnection connection)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var printers = PrinterDiscovery.Discover();
|
||||||
|
await connection.InvokeAsync("ReportPrinters", printers);
|
||||||
|
Console.WriteLine($"[printers] reported {printers.Count}: " +
|
||||||
|
string.Join(", ", printers.Select(p => $"{p.DisplayName} ({p.Kind})")));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[printers] report failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task ConnectWithRetryAsync(HubConnection connection)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
try { await connection.StartAsync(); return; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[hub] connect failed: {ex.Message}; retrying in 5s");
|
||||||
|
await Task.Delay(5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retry reconnecting forever with capped exponential backoff.</summary>
|
||||||
|
sealed class ForeverRetry : IRetryPolicy
|
||||||
|
{
|
||||||
|
public TimeSpan? NextRetryDelay(RetryContext ctx) =>
|
||||||
|
TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, Math.Min(ctx.PreviousRetryCount, 5))));
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
namespace Meezi.PrintAgent;
|
||||||
|
|
||||||
|
/// <summary>Writes raw ESC/POS bytes to a printer — by Windows name (winspool RAW
|
||||||
|
/// passthrough) or to an "ip:port" endpoint (raw TCP).</summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public static class RawPrinter
|
||||||
|
{
|
||||||
|
public static async Task PrintAsync(string systemName, byte[] data, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (TryParseEndpoint(systemName, out var ip, out var port))
|
||||||
|
{
|
||||||
|
await PrintTcpAsync(ip, port, data, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!SendBytesToPrinter(systemName, data))
|
||||||
|
throw new Exception($"winspool write failed (last error {Marshal.GetLastWin32Error()})");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseEndpoint(string s, out string ip, out int port)
|
||||||
|
{
|
||||||
|
ip = "";
|
||||||
|
port = 9100;
|
||||||
|
var idx = s.LastIndexOf(':');
|
||||||
|
if (idx <= 0) return false;
|
||||||
|
var host = s[..idx];
|
||||||
|
if (!host.Contains('.')) return false; // not an IPv4-ish host → treat as printer name
|
||||||
|
if (int.TryParse(s[(idx + 1)..], out var p)) port = p;
|
||||||
|
ip = host;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task PrintTcpAsync(string ip, int port, byte[] data, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
cts.CancelAfter(TimeSpan.FromSeconds(8));
|
||||||
|
await client.ConnectAsync(ip, port, cts.Token);
|
||||||
|
await using var stream = client.GetStream();
|
||||||
|
await stream.WriteAsync(data, cts.Token);
|
||||||
|
await stream.FlushAsync(cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── winspool raw printing ────────────────────────────────────────────────
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct DOCINFOW
|
||||||
|
{
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] public string pDocName;
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] public string? pOutputFile;
|
||||||
|
[MarshalAs(UnmanagedType.LPWStr)] public string pDataType;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern bool OpenPrinter(string src, out IntPtr hPrinter, IntPtr pd);
|
||||||
|
[DllImport("winspool.drv", SetLastError = true)]
|
||||||
|
private static extern bool ClosePrinter(IntPtr hPrinter);
|
||||||
|
[DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern bool StartDocPrinter(IntPtr hPrinter, int level, ref DOCINFOW di);
|
||||||
|
[DllImport("winspool.drv", SetLastError = true)]
|
||||||
|
private static extern bool EndDocPrinter(IntPtr hPrinter);
|
||||||
|
[DllImport("winspool.drv", SetLastError = true)]
|
||||||
|
private static extern bool StartPagePrinter(IntPtr hPrinter);
|
||||||
|
[DllImport("winspool.drv", SetLastError = true)]
|
||||||
|
private static extern bool EndPagePrinter(IntPtr hPrinter);
|
||||||
|
[DllImport("winspool.drv", SetLastError = true)]
|
||||||
|
private static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten);
|
||||||
|
|
||||||
|
private static bool SendBytesToPrinter(string printerName, byte[] bytes)
|
||||||
|
{
|
||||||
|
if (!OpenPrinter(printerName, out var hPrinter, IntPtr.Zero)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var di = new DOCINFOW { pDocName = "Meezi Receipt", pDataType = "RAW" };
|
||||||
|
if (!StartDocPrinter(hPrinter, 1, ref di)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!StartPagePrinter(hPrinter)) return false;
|
||||||
|
var ptr = Marshal.AllocHGlobal(bytes.Length);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Marshal.Copy(bytes, 0, ptr, bytes.Length);
|
||||||
|
if (!WritePrinter(hPrinter, ptr, bytes.Length, out _)) return false;
|
||||||
|
}
|
||||||
|
finally { Marshal.FreeHGlobal(ptr); }
|
||||||
|
EndPagePrinter(hPrinter);
|
||||||
|
}
|
||||||
|
finally { EndDocPrinter(hPrinter); }
|
||||||
|
}
|
||||||
|
finally { ClosePrinter(hPrinter); }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Meezi Print Agent (پرینتسرور میزی)
|
||||||
|
|
||||||
|
A tiny Windows background app that lets the **cloud-hosted** Meezi reach printers on
|
||||||
|
the café's **local network** (USB or Wi-Fi/Ethernet). The cloud can't open a
|
||||||
|
connection to a `192.168.x.x` or USB printer directly — this agent runs on the cash
|
||||||
|
PC (which *is* on that network), connects **outward** to Meezi over SignalR, reports
|
||||||
|
the printers it can see, and prints the jobs the cloud sends it.
|
||||||
|
|
||||||
|
```
|
||||||
|
Cloud API ──SignalR(out)──► Print Agent (cash PC) ──► USB / LAN printers
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
1. In the dashboard: **Settings → Printers → Add print server** → you get a pairing code.
|
||||||
|
2. Run the agent on the cash PC, enter the code once. It saves a token to
|
||||||
|
`%APPDATA%\MeeziPrintAgent\config.json` and connects.
|
||||||
|
3. It reports every printer installed on that PC. Back in the dashboard you map
|
||||||
|
*receipt / kitchen / bar* to a printer from the dropdown — no IP typing.
|
||||||
|
4. When Meezi prints, the bytes (ESC/POS) are relayed to the agent, which writes them
|
||||||
|
raw to the chosen printer (`winspool` for installed printers, raw TCP for
|
||||||
|
`ip:port` devices).
|
||||||
|
|
||||||
|
## Build & run (dev)
|
||||||
|
Requires the .NET 10 SDK on Windows.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# restore via the Nexus mirror (nuget.org is blocked on this network)
|
||||||
|
dotnet restore agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj -s https://mirror.soroushasadi.com/repository/nuget-group/
|
||||||
|
dotnet run --project agent/Meezi.PrintAgent # first run prompts to pair
|
||||||
|
dotnet run --project agent/Meezi.PrintAgent -- pair # re-pair later
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publish a single .exe for cafés
|
||||||
|
```sh
|
||||||
|
dotnet publish agent/Meezi.PrintAgent -c Release -r win-x64 \
|
||||||
|
-p:PublishSingleFile=true --self-contained true -o dist/agent
|
||||||
|
# → dist/agent/MeeziPrintAgent.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes / roadmap
|
||||||
|
- **Not part of the API solution or CI** — it targets `net10.0-windows` and builds on its own.
|
||||||
|
- Console MVP today. Next: system-tray UI, run-at-login (Task Scheduler / service), auto-update, and an optional LAN scan for raw `ip:9100` printers that aren't installed in Windows.
|
||||||
|
- The token is bearer-equivalent — keep `config.json` on a trusted machine. Revoke from the dashboard if a PC is lost.
|
||||||
@@ -26,7 +26,7 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
|
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Production}"
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
|
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
|
||||||
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ services:
|
|||||||
dockerfile: docker/website/Dockerfile
|
dockerfile: docker/website/Dockerfile
|
||||||
args:
|
args:
|
||||||
MEEZI_API_URL: http://api:8080
|
MEEZI_API_URL: http://api:8080
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}
|
||||||
container_name: meezi-website
|
container_name: meezi-website
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -178,7 +178,7 @@ services:
|
|||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
MEEZI_API_URL: http://api:8080
|
MEEZI_API_URL: http://api:8080
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}
|
||||||
ports:
|
ports:
|
||||||
- "${WEBSITE_PORT:-3010}:3000"
|
- "${WEBSITE_PORT:-3010}:3000"
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
|
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Production}"
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
|
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
|
||||||
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
||||||
@@ -94,6 +94,10 @@ services:
|
|||||||
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
||||||
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
||||||
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
||||||
|
FlatPay__ApiKey: "${FLATPAY_API_KEY:-}"
|
||||||
|
FlatPay__Secret: "${FLATPAY_SECRET:-}"
|
||||||
|
FlatPay__BaseUrl: "${FLATPAY_BASE_URL:-https://pay.flatrender.ir}"
|
||||||
|
FlatPay__ReturnUrl: "${FLATPAY_RETURN_URL:-https://meezi.ir/payment/return}"
|
||||||
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
|
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
|
||||||
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
|
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
|
||||||
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
|
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
|
||||||
@@ -139,7 +143,7 @@ services:
|
|||||||
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
||||||
MEEZI_API_URL: http://api:8080
|
MEEZI_API_URL: http://api:8080
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}
|
||||||
container_name: meezi-website
|
container_name: meezi-website
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -149,7 +153,7 @@ services:
|
|||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
MEEZI_API_URL: http://api:8080
|
MEEZI_API_URL: http://api:8080
|
||||||
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}"
|
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}"
|
||||||
ports:
|
ports:
|
||||||
- "${WEBSITE_PORT:-3010}:3000"
|
- "${WEBSITE_PORT:-3010}:3000"
|
||||||
|
|
||||||
@@ -163,7 +167,7 @@ services:
|
|||||||
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-https://koja.meezi.ir}
|
||||||
container_name: meezi-koja
|
container_name: meezi-koja
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -173,10 +177,34 @@ services:
|
|||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://localhost:5080}"
|
NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://localhost:5080}"
|
||||||
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103}"
|
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_KOJA_URL:-https://koja.meezi.ir}"
|
||||||
ports:
|
ports:
|
||||||
- "${KOJA_PORT:-3103}:3000"
|
- "${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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_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)
|
# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
|
||||||
COPY nuget.docker.config ./nuget.config
|
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.Shared/Meezi.Shared.csproj src/Meezi.Shared/
|
||||||
COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/
|
COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/
|
||||||
COPY src/Meezi.Infrastructure/Meezi.Infrastructure.csproj src/Meezi.Infrastructure/
|
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)
|
# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
|
||||||
COPY nuget.docker.config ./nuget.config
|
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.Shared/Meezi.Shared.csproj src/Meezi.Shared/
|
||||||
COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/
|
COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/
|
||||||
COPY src/Meezi.Infrastructure/Meezi.Infrastructure.csproj src/Meezi.Infrastructure/
|
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-----
|
||||||
@@ -23,7 +23,7 @@ FROM ${NODE_IMAGE} AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG MEEZI_API_URL=http://api:8080
|
ARG MEEZI_API_URL=http://api:8080
|
||||||
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3010
|
ARG NEXT_PUBLIC_SITE_URL=https://meezi.ir
|
||||||
|
|
||||||
ENV MEEZI_API_URL=$MEEZI_API_URL
|
ENV MEEZI_API_URL=$MEEZI_API_URL
|
||||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||||
|
|||||||
@@ -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 description = cafe['description'] as String?;
|
||||||
final address = cafe['address'] as String?;
|
final address = cafe['address'] as String?;
|
||||||
final city = cafe['city'] 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(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
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),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -117,6 +170,59 @@ class _CafeDetailScreenState extends ConsumerState<CafeDetailScreen> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(description),
|
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),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -4,22 +4,91 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../cart/cart_state.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>(
|
final String? q;
|
||||||
(_) => (q: null, minRating: null, sort: 'rating'),
|
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) {
|
int get activeCount =>
|
||||||
final filters = ref.watch(discoverFiltersProvider);
|
(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(
|
return ref.watch(publicApiProvider).discover(
|
||||||
city: 'تهران',
|
city: 'تهران',
|
||||||
q: filters.q,
|
q: f.q,
|
||||||
minRating: filters.minRating,
|
minRating: f.minRating,
|
||||||
sort: filters.sort,
|
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 {
|
class DiscoverScreen extends ConsumerStatefulWidget {
|
||||||
const DiscoverScreen({super.key});
|
const DiscoverScreen({super.key});
|
||||||
|
|
||||||
@@ -39,10 +108,19 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
void _applySearch() {
|
void _applySearch() {
|
||||||
final q = _searchController.text.trim();
|
final q = _searchController.text.trim();
|
||||||
ref.read(discoverFiltersProvider.notifier).update(
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cafesAsync = ref.watch(discoverProvider);
|
final cafesAsync = ref.watch(discoverProvider);
|
||||||
@@ -70,17 +148,27 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'جستجوی نام کافه...',
|
hintText: 'کافه دنج برای کار، نزدیک من...',
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
isDense: true,
|
isDense: true,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: _applySearch,
|
onPressed: _applySearch,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
onSubmitted: (_) => _applySearch(),
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
FilterChip(
|
FilterChip(
|
||||||
label: const Text('همه'),
|
label: const Text('باز است'),
|
||||||
selected: filters.minRating == null,
|
selected: filters.openNow,
|
||||||
onSelected: (_) {
|
onSelected: (v) => ref
|
||||||
ref.read(discoverFiltersProvider.notifier).update(
|
.read(discoverFiltersProvider.notifier)
|
||||||
(s) => (q: s.q, minRating: null, sort: s.sort),
|
.update((s) => s.copyWith(openNow: v)),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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])
|
for (final min in [3.0, 4.0, 4.5])
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: FilterChip(
|
child: FilterChip(
|
||||||
label: Text('★ $min+'),
|
label: Text('★ $min+'),
|
||||||
selected: filters.minRating == min,
|
selected: filters.minRating == min,
|
||||||
onSelected: (_) {
|
onSelected: (_) => ref
|
||||||
ref.read(discoverFiltersProvider.notifier).update(
|
.read(discoverFiltersProvider.notifier)
|
||||||
(s) => (q: s.q, minRating: min, sort: s.sort),
|
.update((s) => s.copyWith(minRating: () => min)),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -118,10 +209,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
value: filters.sort,
|
initialValue: filters.sort,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'مرتبسازی',
|
labelText: 'مرتبسازی',
|
||||||
border: OutlineInputBorder(),
|
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
items: const [
|
items: const [
|
||||||
@@ -131,9 +221,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
],
|
],
|
||||||
onChanged: (sort) {
|
onChanged: (sort) {
|
||||||
if (sort == null) return;
|
if (sort == null) return;
|
||||||
ref.read(discoverFiltersProvider.notifier).update(
|
ref
|
||||||
(s) => (q: s.q, minRating: s.minRating, sort: sort),
|
.read(discoverFiltersProvider.notifier)
|
||||||
);
|
.update((s) => s.copyWith(sort: sort));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -143,36 +233,19 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
if (cafes.isEmpty) {
|
if (cafes.isEmpty) {
|
||||||
return const Center(child: Text('کافهای یافت نشد'));
|
return const Center(child: Text('کافهای یافت نشد'));
|
||||||
}
|
}
|
||||||
return ListView.separated(
|
return RefreshIndicator(
|
||||||
padding: const EdgeInsets.all(16),
|
onRefresh: () async => ref.refresh(discoverProvider.future),
|
||||||
itemCount: cafes.length,
|
child: ListView.separated(
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
padding: const EdgeInsets.all(16),
|
||||||
itemBuilder: (context, index) {
|
itemCount: cafes.length,
|
||||||
final cafe = cafes[index];
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
final slug = cafe['slug'] as String;
|
itemBuilder: (context, index) =>
|
||||||
final name = cafe['name'] as String? ?? slug;
|
_CafeCard(cafe: cafes[index]),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () =>
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('خطا: $e')),
|
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),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'امروز: ${todayJalali.formatter.yyyyMMdd()}',
|
'امروز: ${todayJalali.year}/${todayJalali.month.toString().padLeft(2, '0')}/${todayJalali.day.toString().padLeft(2, '0')}',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -10,12 +10,28 @@ class PublicApi {
|
|||||||
String? q,
|
String? q,
|
||||||
double? minRating,
|
double? minRating,
|
||||||
String? sort,
|
String? sort,
|
||||||
|
List<String>? themes,
|
||||||
|
List<String>? vibes,
|
||||||
|
List<String>? occasions,
|
||||||
|
List<String>? spaceFeatures,
|
||||||
|
String? noise,
|
||||||
|
String? priceTier,
|
||||||
|
String? size,
|
||||||
|
bool openNow = false,
|
||||||
}) async {
|
}) async {
|
||||||
final params = <String, String>{};
|
final params = <String, String>{};
|
||||||
if (city != null && city.isNotEmpty) params['city'] = city;
|
if (city != null && city.isNotEmpty) params['city'] = city;
|
||||||
if (q != null && q.isNotEmpty) params['q'] = q;
|
if (q != null && q.isNotEmpty) params['q'] = q;
|
||||||
if (minRating != null) params['minRating'] = minRating.toString();
|
if (minRating != null) params['minRating'] = minRating.toString();
|
||||||
if (sort != null && sort.isNotEmpty) params['sort'] = sort;
|
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>>(
|
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||||
'/api/public/discover',
|
'/api/public/discover',
|
||||||
queryParameters: params.isEmpty ? null : params,
|
queryParameters: params.isEmpty ? null : params,
|
||||||
@@ -24,6 +40,43 @@ class PublicApi {
|
|||||||
return list.cast<Map<String, dynamic>>();
|
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 {
|
Future<List<Map<String, dynamic>>> getReviews(String slug, {int page = 1}) async {
|
||||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||||
'/api/public/cafes/$slug/reviews',
|
'/api/public/cafes/$slug/reviews',
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ class _QrScanScreenState extends ConsumerState<QrScanScreen> {
|
|||||||
tableNumber: tableNumber ?? '',
|
tableNumber: tableNumber ?? '',
|
||||||
cafeSlug: slug,
|
cafeSlug: slug,
|
||||||
);
|
);
|
||||||
if (tableId != null) {
|
|
||||||
ref.read(cartProvider.notifier).setTable(tableId);
|
|
||||||
}
|
|
||||||
context.push(
|
context.push(
|
||||||
'/cafe/$slug/menu?tableId=$tableId&tableNumber=$tableNumber',
|
'/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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'app/router.dart';
|
import 'app/router.dart';
|
||||||
|
import 'core/theme/app_theme.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const ProviderScope(child: MeeziApp()));
|
runApp(const ProviderScope(child: MeeziApp()));
|
||||||
@@ -22,10 +23,9 @@ class MeeziApp extends StatelessWidget {
|
|||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
theme: ThemeData(
|
theme: MeeziTheme.light(),
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6B4F3A)),
|
darkTheme: MeeziTheme.dark(),
|
||||||
useMaterial3: true,
|
themeMode: ThemeMode.light,
|
||||||
),
|
|
||||||
routerConfig: appRouter,
|
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
|
shamsi_date: ^1.1.1
|
||||||
flutter_secure_storage: ^9.2.2
|
flutter_secure_storage: ^9.2.2
|
||||||
shared_preferences: ^2.3.2
|
shared_preferences: ^2.3.2
|
||||||
intl: ^0.19.0
|
intl: ^0.20.2
|
||||||
uuid: ^4.4.2
|
uuid: ^4.4.2
|
||||||
mobile_scanner: ^5.2.3
|
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
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Audit;
|
using Meezi.API.Models.Audit;
|
||||||
using Meezi.Core.Authorization;
|
using Meezi.Core.Authorization;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -42,7 +43,7 @@ public class AuditController : CafeApiControllerBase
|
|||||||
[FromQuery] int pageSize = 50)
|
[FromQuery] int pageSize = 50)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsurePermission(tenant, Permission.ViewReports) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ViewAuditLog) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
if (page < 1) page = 1;
|
if (page < 1) page = 1;
|
||||||
if (pageSize < 1) pageSize = 50;
|
if (pageSize < 1) pageSize = 50;
|
||||||
@@ -67,25 +68,50 @@ public class AuditController : CafeApiControllerBase
|
|||||||
|
|
||||||
var total = await query.CountAsync(ct);
|
var total = await query.CountAsync(ct);
|
||||||
|
|
||||||
var items = await query
|
var rows = await query
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.Select(x => new AuditLogDto(
|
.Select(x => new
|
||||||
x.Id,
|
{
|
||||||
x.Category,
|
x.Id, x.Category, x.Action, x.EntityType, x.EntityId, x.BranchId,
|
||||||
x.Action,
|
x.ActorId, x.ActorName, x.ActorRole, x.Summary, x.DetailsJson, x.CreatedAt
|
||||||
x.EntityType,
|
})
|
||||||
x.EntityId,
|
|
||||||
x.BranchId,
|
|
||||||
x.ActorId,
|
|
||||||
x.ActorName,
|
|
||||||
x.ActorRole,
|
|
||||||
x.Summary,
|
|
||||||
x.DetailsJson,
|
|
||||||
x.CreatedAt))
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Resolve the actor's CURRENT full name + role from the employee record.
|
||||||
|
// This fixes historical rows (where ActorName was never stored) and keeps
|
||||||
|
// names current. IgnoreQueryFilters so we still name soft-deleted staff.
|
||||||
|
var actorIds = rows
|
||||||
|
.Where(r => !string.IsNullOrEmpty(r.ActorId))
|
||||||
|
.Select(r => r.ActorId!)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var employees = actorIds.Count == 0
|
||||||
|
? new Dictionary<string, (string Name, EmployeeRole Role)>()
|
||||||
|
: (await _db.Employees
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.CafeId == cafeId && actorIds.Contains(e.Id))
|
||||||
|
.Select(e => new { e.Id, e.Name, e.Role })
|
||||||
|
.ToListAsync(ct))
|
||||||
|
.ToDictionary(e => e.Id, e => (e.Name, e.Role));
|
||||||
|
|
||||||
|
var items = rows.Select(r =>
|
||||||
|
{
|
||||||
|
string? name = r.ActorName;
|
||||||
|
string? role = r.ActorRole;
|
||||||
|
if (!string.IsNullOrEmpty(r.ActorId) && employees.TryGetValue(r.ActorId, out var emp))
|
||||||
|
{
|
||||||
|
name = emp.Name; // prefer the live employee name
|
||||||
|
role ??= emp.Role.ToString();
|
||||||
|
}
|
||||||
|
return new AuditLogDto(
|
||||||
|
r.Id, r.Category, r.Action, r.EntityType, r.EntityId, r.BranchId,
|
||||||
|
r.ActorId, name, role, r.Summary, r.DetailsJson, r.CreatedAt);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
return Ok(new PagedApiResponse<AuditLogDto>(true, items, new PagedMeta(total, page, pageSize)));
|
return Ok(new PagedApiResponse<AuditLogDto>(true, items, new PagedMeta(total, page, pageSize)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,23 @@ public class AuthController : ControllerBase
|
|||||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
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")]
|
[HttpPost("send-otp")]
|
||||||
[EnableRateLimiting("auth-otp")]
|
[EnableRateLimiting("auth-otp")]
|
||||||
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
||||||
@@ -198,7 +215,10 @@ public class AuthController : ControllerBase
|
|||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty,
|
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,
|
PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty,
|
||||||
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
|
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
|
||||||
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
|
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
|
||||||
@@ -221,7 +241,9 @@ public class AuthController : ControllerBase
|
|||||||
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
||||||
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
"INVALID_OTP" or "INVALID_TOKEN" 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,
|
"BRANCH_FORBIDDEN" => StatusCode(StatusCodes.Status403Forbidden,
|
||||||
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Billing;
|
using Meezi.API.Models.Billing;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
public class BillingController : ControllerBase
|
public class BillingController : CafeApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IBillingService _billing;
|
private readonly IBillingService _billing;
|
||||||
private readonly IValidator<SubscribeRequest> _subscribeValidator;
|
private readonly IValidator<SubscribeRequest> _subscribeValidator;
|
||||||
@@ -27,13 +28,9 @@ public class BillingController : ControllerBase
|
|||||||
ITenantContext tenant,
|
ITenantContext tenant,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrEmpty(tenant.CafeId))
|
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||||
return Unauthorized();
|
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 validation = await _subscribeValidator.ValidateAsync(request, ct);
|
var validation = await _subscribeValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
@@ -103,4 +100,23 @@ public class BillingController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new ApiResponse<BillingStatusDto>(true, data));
|
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 (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
|
||||||
|
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Menu;
|
using Meezi.API.Models.Menu;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -43,7 +43,6 @@ public class BranchMenuController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{menuItemId}/override")]
|
[HttpPut("{menuItemId}/override")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> UpsertOverride(
|
public async Task<IActionResult> UpsertOverride(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -53,8 +52,7 @@ public class BranchMenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (!BranchMenuService.CanManageOverrides(tenant.Role))
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
return Forbid();
|
|
||||||
|
|
||||||
var validation = await _upsertValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _upsertValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
@@ -84,7 +82,6 @@ public class BranchMenuController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{menuItemId}/override")]
|
[HttpDelete("{menuItemId}/override")]
|
||||||
[Authorize(Roles = "Owner")]
|
|
||||||
public async Task<IActionResult> DeleteOverride(
|
public async Task<IActionResult> DeleteOverride(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -93,6 +90,7 @@ public class BranchMenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var deleted = await _branchMenu.DeleteOverrideAsync(
|
var deleted = await _branchMenu.DeleteOverrideAsync(
|
||||||
cafeId, branchId, menuItemId, cancellationToken);
|
cafeId, branchId, menuItemId, cancellationToken);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Printing;
|
using Meezi.API.Models.Printing;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Entities;
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
@@ -11,7 +11,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
[Route("api/cafes/{cafeId}/branches/{branchId}/print-settings")]
|
[Route("api/cafes/{cafeId}/branches/{branchId}/print-settings")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public class BranchPrintSettingsController : CafeApiControllerBase
|
public class BranchPrintSettingsController : CafeApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
@@ -54,6 +53,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var validation = await _validator.ValidateAsync(request, ct);
|
var validation = await _validator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
@@ -91,6 +91,14 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
|||||||
: request.PosDeviceIp.Trim();
|
: request.PosDeviceIp.Trim();
|
||||||
if (request.PosDevicePort.HasValue)
|
if (request.PosDevicePort.HasValue)
|
||||||
branch.PosDevicePort = request.PosDevicePort.Value;
|
branch.PosDevicePort = request.PosDevicePort.Value;
|
||||||
|
if (request.ReceiptPrintDeviceId is not null)
|
||||||
|
branch.ReceiptPrintDeviceId = string.IsNullOrWhiteSpace(request.ReceiptPrintDeviceId)
|
||||||
|
? null
|
||||||
|
: request.ReceiptPrintDeviceId;
|
||||||
|
if (request.KitchenPrintDeviceId is not null)
|
||||||
|
branch.KitchenPrintDeviceId = string.IsNullOrWhiteSpace(request.KitchenPrintDeviceId)
|
||||||
|
? null
|
||||||
|
: request.KitchenPrintDeviceId;
|
||||||
|
|
||||||
branch.UpdatedAt = DateTime.UtcNow;
|
branch.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
@@ -110,5 +118,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
|||||||
b.ReceiptFooter,
|
b.ReceiptFooter,
|
||||||
b.WifiPassword,
|
b.WifiPassword,
|
||||||
b.PosDeviceIp,
|
b.PosDeviceIp,
|
||||||
b.PosDevicePort);
|
b.PosDevicePort,
|
||||||
|
b.ReceiptPrintDeviceId,
|
||||||
|
b.KitchenPrintDeviceId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Tables;
|
using Meezi.API.Models.Tables;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -68,7 +68,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> CreateTable(
|
public async Task<IActionResult> CreateTable(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -77,6 +76,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -88,7 +88,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id}")]
|
[HttpPatch("{id}")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> PatchTable(
|
public async Task<IActionResult> PatchTable(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -98,6 +97,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -109,7 +109,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> DeleteTable(
|
public async Task<IActionResult> DeleteTable(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -118,6 +117,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -135,6 +135,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -180,7 +181,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sections")]
|
[HttpPost("sections")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> CreateSection(
|
public async Task<IActionResult> CreateSection(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -189,6 +189,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -200,7 +201,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("sections/{sectionId}")]
|
[HttpPatch("sections/{sectionId}")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> PatchSection(
|
public async Task<IActionResult> PatchSection(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -210,6 +210,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -221,7 +222,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("sections/{sectionId}")]
|
[HttpDelete("sections/{sectionId}")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> DeleteSection(
|
public async Task<IActionResult> DeleteSection(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -230,6 +230,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Branches;
|
using Meezi.API.Models.Branches;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Constants;
|
using Meezi.Core.Constants;
|
||||||
using Meezi.Core.Entities;
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
@@ -96,7 +97,7 @@ public class BranchesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
if (EnsurePermission(tenant, Permission.CreateBranch) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
@@ -169,7 +170,7 @@ public class BranchesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
if (EnsurePermission(tenant, Permission.EditBranch) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var validation = await _patchValidator.ValidateAsync(request, ct);
|
var validation = await _patchValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
@@ -222,7 +223,7 @@ public class BranchesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
if (EnsurePermission(tenant, Permission.DeleteBranch) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var (ok, code, message) = await _lifecycle.ScheduleDeletionAsync(cafeId, branchId, ct);
|
var (ok, code, message) = await _lifecycle.ScheduleDeletionAsync(cafeId, branchId, ct);
|
||||||
if (!ok)
|
if (!ok)
|
||||||
@@ -257,7 +258,7 @@ public class BranchesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
if (EnsurePermission(tenant, Permission.EditBranch) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var (ok, code, message) = await _lifecycle.RestoreAsync(cafeId, branchId, ct);
|
var (ok, code, message) = await _lifecycle.RestoreAsync(cafeId, branchId, ct);
|
||||||
if (!ok)
|
if (!ok)
|
||||||
|
|||||||
@@ -44,9 +44,14 @@ public abstract class CafeApiControllerBase : ControllerBase
|
|||||||
return EnsureManager(tenant);
|
return EnsureManager(tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gate by an explicit capability from the role→permission matrix.</summary>
|
/// <summary>Gate by an explicit capability from the role→permission matrix.
|
||||||
|
/// When the employee has a custom role its permission set is used instead.</summary>
|
||||||
protected IActionResult? EnsurePermission(ITenantContext tenant, Permission permission)
|
protected IActionResult? EnsurePermission(ITenantContext tenant, Permission permission)
|
||||||
{
|
{
|
||||||
|
if (tenant.CustomPermissions is { } custom)
|
||||||
|
return custom.Contains(permission)
|
||||||
|
? null
|
||||||
|
: Forbidden("FORBIDDEN", "You do not have permission to perform this action.");
|
||||||
if (tenant.Role is { } role && RolePermissions.Has(role, permission))
|
if (tenant.Role is { } role && RolePermissions.Has(role, permission))
|
||||||
return null;
|
return null;
|
||||||
return Forbidden("FORBIDDEN", "You do not have permission to perform this action.");
|
return Forbidden("FORBIDDEN", "You do not have permission to perform this action.");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Discover;
|
using Meezi.API.Models.Discover;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
@@ -45,6 +46,7 @@ public class CafeDiscoverProfileController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied)
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied)
|
||||||
return denied;
|
return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "discover_profile", ct))
|
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "discover_profile", ct))
|
||||||
|
|||||||
@@ -36,4 +36,13 @@ public class CafePlatformController : CafeApiControllerBase
|
|||||||
var plans = await _catalog.GetPlansAsync(cancellationToken);
|
var plans = await _catalog.GetPlansAsync(cancellationToken);
|
||||||
return Ok(new ApiResponse<object>(true, plans));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Discover;
|
using Meezi.Core.Discover;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
@@ -57,7 +58,8 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
gallery,
|
gallery,
|
||||||
cafe.InstagramHandle,
|
cafe.InstagramHandle,
|
||||||
cafe.WebsiteUrl,
|
cafe.WebsiteUrl,
|
||||||
ToHoursDto(hours))));
|
ToHoursDto(hours),
|
||||||
|
cafe.ShowOnKoja)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PUT (description / social / hours) ───────────────────────────────────
|
// ── PUT (description / social / hours) ───────────────────────────────────
|
||||||
@@ -70,6 +72,7 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
||||||
if (cafe is null)
|
if (cafe is null)
|
||||||
@@ -91,6 +94,10 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
if (request.WorkingHours is not null)
|
if (request.WorkingHours is not null)
|
||||||
cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts);
|
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);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
|
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
|
||||||
@@ -101,7 +108,8 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
gallery,
|
gallery,
|
||||||
cafe.InstagramHandle,
|
cafe.InstagramHandle,
|
||||||
cafe.WebsiteUrl,
|
cafe.WebsiteUrl,
|
||||||
ToHoursDto(hours))));
|
ToHoursDto(hours),
|
||||||
|
cafe.ShowOnKoja)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST gallery/upload ───────────────────────────────────────────────────
|
// ── POST gallery/upload ───────────────────────────────────────────────────
|
||||||
@@ -115,6 +123,7 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
if (photo is null || photo.Length == 0)
|
if (photo is null || photo.Length == 0)
|
||||||
return BadRequest(Fail("NO_FILE", "No photo provided."));
|
return BadRequest(Fail("NO_FILE", "No photo provided."));
|
||||||
@@ -149,6 +158,7 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
return BadRequest(Fail("NO_URL", "Provide ?url= of the photo to remove."));
|
return BadRequest(Fail("NO_URL", "Provide ?url= of the photo to remove."));
|
||||||
@@ -207,13 +217,15 @@ public record UpdateCafePublicProfileRequest(
|
|||||||
string? Description,
|
string? Description,
|
||||||
string? InstagramHandle,
|
string? InstagramHandle,
|
||||||
string? WebsiteUrl,
|
string? WebsiteUrl,
|
||||||
WorkingHoursPublicDto? WorkingHours);
|
WorkingHoursPublicDto? WorkingHours,
|
||||||
|
bool? ShowOnKoja = null);
|
||||||
|
|
||||||
public record CafeProfileEditDto(
|
public record CafeProfileEditDto(
|
||||||
string? Description,
|
string? Description,
|
||||||
IReadOnlyList<string> GalleryUrls,
|
IReadOnlyList<string> GalleryUrls,
|
||||||
string? InstagramHandle,
|
string? InstagramHandle,
|
||||||
string? WebsiteUrl,
|
string? WebsiteUrl,
|
||||||
WorkingHoursPublicDto? WorkingHours);
|
WorkingHoursPublicDto? WorkingHours,
|
||||||
|
bool ShowOnKoja);
|
||||||
|
|
||||||
public record GalleryDto(IReadOnlyList<string> GalleryUrls);
|
public record GalleryDto(IReadOnlyList<string> GalleryUrls);
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -12,11 +15,16 @@ public class CafeReviewsController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IReviewService _reviews;
|
private readonly IReviewService _reviews;
|
||||||
private readonly IValidator<ReplyCafeReviewRequest> _replyValidator;
|
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;
|
_reviews = reviews;
|
||||||
_replyValidator = replyValidator;
|
_replyValidator = replyValidator;
|
||||||
|
_catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -41,6 +49,14 @@ public class CafeReviewsController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageReviews) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
// 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);
|
var validation = await _replyValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
{
|
{
|
||||||
@@ -62,6 +78,7 @@ public class CafeReviewsController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageReviews) is { } permDenied) return permDenied;
|
||||||
var data = await _reviews.SetHiddenAsync(cafeId, reviewId, request.IsHidden, ct);
|
var data = await _reviews.SetHiddenAsync(cafeId, reviewId, request.IsHidden, ct);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<CafeReviewDto>(true, data));
|
return Ok(new ApiResponse<CafeReviewDto>(true, data));
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Cafes;
|
using Meezi.API.Models.Cafes;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Core.Utilities;
|
using Meezi.Core.Utilities;
|
||||||
using Meezi.Infrastructure.Branding;
|
using Meezi.Infrastructure.Branding;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -16,11 +19,16 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IValidator<PatchCafeSettingsRequest> _validator;
|
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;
|
_db = db;
|
||||||
_validator = validator;
|
_validator = validator;
|
||||||
|
_catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -40,6 +48,7 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var validation = await _validator.ValidateAsync(request, ct);
|
var validation = await _validator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
@@ -81,7 +90,19 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim();
|
if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim();
|
||||||
if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim();
|
if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim();
|
||||||
if (request.Theme is not null)
|
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)
|
if (request.DefaultTaxRate is decimal taxRate)
|
||||||
cafe.DefaultTaxRate = taxRate;
|
cafe.DefaultTaxRate = taxRate;
|
||||||
if (request.AllowBranchTaxOverride is bool allowTax)
|
if (request.AllowBranchTaxOverride is bool allowTax)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Crm;
|
using Meezi.API.Models.Crm;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ public class CouponsController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateCoupon) is { } permDenied) return permDenied;
|
||||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ public class CouponsController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditCoupon) is { } permDenied) return permDenied;
|
||||||
var data = await _couponService.UpdateAsync(cafeId, id, request, cancellationToken);
|
var data = await _couponService.UpdateAsync(cafeId, id, request, cancellationToken);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<CouponDto>(true, data));
|
return Ok(new ApiResponse<CouponDto>(true, data));
|
||||||
@@ -95,6 +98,7 @@ public class CouponsController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteCoupon) is { } permDenied) return permDenied;
|
||||||
var deleted = await _couponService.DeleteAsync(cafeId, id, cancellationToken);
|
var deleted = await _couponService.DeleteAsync(cafeId, id, cancellationToken);
|
||||||
if (!deleted) return NotFoundError();
|
if (!deleted) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id }));
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Meezi.API.Models.CustomRoles;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
|
using Meezi.Core.Entities;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Meezi.Shared;
|
||||||
|
|
||||||
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
|
[Route("api/cafes/{cafeId}/custom-roles")]
|
||||||
|
public class CustomRolesController : CafeApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public CustomRolesController(AppDbContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
|
var roles = await _db.CustomRoles
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(r => r.CafeId == cafeId)
|
||||||
|
.OrderBy(r => r.Name)
|
||||||
|
.Select(r => new
|
||||||
|
{
|
||||||
|
r.Id,
|
||||||
|
r.Name,
|
||||||
|
r.Description,
|
||||||
|
r.Color,
|
||||||
|
r.PermissionsJson,
|
||||||
|
EmployeeCount = _db.Employees.Count(e => e.CafeId == cafeId && e.CustomRoleId == r.Id && e.DeletedAt == null),
|
||||||
|
r.CreatedAt,
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var dtos = roles.Select(r => new CustomRoleDto(
|
||||||
|
r.Id,
|
||||||
|
r.Name,
|
||||||
|
r.Description,
|
||||||
|
r.Color,
|
||||||
|
CustomRolePermissions.Parse(r.PermissionsJson).Select(p => p.ToString()).OrderBy(p => p).ToList(),
|
||||||
|
r.EmployeeCount,
|
||||||
|
r.CreatedAt)).ToList();
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<IReadOnlyList<CustomRoleDto>>(true, dtos));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> Get(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
|
var r = await _db.CustomRoles.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id && x.CafeId == cafeId, ct);
|
||||||
|
if (r is null) return NotFoundError("Custom role not found.");
|
||||||
|
|
||||||
|
var employeeCount = await _db.Employees
|
||||||
|
.CountAsync(e => e.CafeId == cafeId && e.CustomRoleId == id && e.DeletedAt == null, ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<CustomRoleDto>(true, new CustomRoleDto(
|
||||||
|
r.Id, r.Name, r.Description, r.Color,
|
||||||
|
CustomRolePermissions.Parse(r.PermissionsJson).Select(p => p.ToString()).OrderBy(p => p).ToList(),
|
||||||
|
employeeCount, r.CreatedAt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create(
|
||||||
|
string cafeId,
|
||||||
|
[FromBody] CreateCustomRoleRequest request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
|
var name = request.Name?.Trim() ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required.", "Name")));
|
||||||
|
|
||||||
|
var permissions = ParseAndValidatePermissions(request.Permissions);
|
||||||
|
|
||||||
|
var role = new CustomRole
|
||||||
|
{
|
||||||
|
CafeId = cafeId,
|
||||||
|
Name = name,
|
||||||
|
Description = request.Description?.Trim(),
|
||||||
|
Color = NormalizeColor(request.Color),
|
||||||
|
PermissionsJson = CustomRolePermissions.Serialize(permissions),
|
||||||
|
};
|
||||||
|
|
||||||
|
_db.CustomRoles.Add(role);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(Get), new { cafeId, id = role.Id },
|
||||||
|
new ApiResponse<CustomRoleDto>(true, ToDto(role, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id}")]
|
||||||
|
public async Task<IActionResult> Update(
|
||||||
|
string cafeId,
|
||||||
|
string id,
|
||||||
|
[FromBody] UpdateCustomRoleRequest request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
|
var role = await _db.CustomRoles
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct);
|
||||||
|
if (role is null) return NotFoundError("Custom role not found.");
|
||||||
|
|
||||||
|
if (request.Name is not null)
|
||||||
|
{
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name cannot be empty.", "Name")));
|
||||||
|
role.Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Description is not null)
|
||||||
|
role.Description = request.Description.Trim().Length > 0 ? request.Description.Trim() : null;
|
||||||
|
|
||||||
|
if (request.Color is not null)
|
||||||
|
role.Color = NormalizeColor(request.Color);
|
||||||
|
|
||||||
|
if (request.Permissions is not null)
|
||||||
|
role.PermissionsJson = CustomRolePermissions.Serialize(ParseAndValidatePermissions(request.Permissions));
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
var employeeCount = await _db.Employees
|
||||||
|
.CountAsync(e => e.CafeId == cafeId && e.CustomRoleId == id && e.DeletedAt == null, ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<CustomRoleDto>(true, ToDto(role, employeeCount)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> Delete(
|
||||||
|
string cafeId,
|
||||||
|
string id,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
|
var role = await _db.CustomRoles
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct);
|
||||||
|
if (role is null) return NotFoundError("Custom role not found.");
|
||||||
|
|
||||||
|
// Unassign employees before deletion so they fall back to their base role permissions.
|
||||||
|
await _db.Employees
|
||||||
|
.Where(e => e.CafeId == cafeId && e.CustomRoleId == id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(e => e.CustomRoleId, (string?)null), ct);
|
||||||
|
|
||||||
|
role.DeletedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Employee custom-role assignment ───────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpPut("/api/cafes/{cafeId}/employees/{employeeId}/custom-role")]
|
||||||
|
public async Task<IActionResult> AssignToEmployee(
|
||||||
|
string cafeId,
|
||||||
|
string employeeId,
|
||||||
|
[FromBody] AssignCustomRoleRequest request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
|
var employee = await _db.Employees
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
||||||
|
if (employee is null) return NotFoundError("Employee not found.");
|
||||||
|
|
||||||
|
if (request.CustomRoleId is not null)
|
||||||
|
{
|
||||||
|
var roleExists = await _db.CustomRoles
|
||||||
|
.AnyAsync(r => r.Id == request.CustomRoleId && r.CafeId == cafeId && r.DeletedAt == null, ct);
|
||||||
|
if (!roleExists)
|
||||||
|
return NotFoundError("Custom role not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
employee.CustomRoleId = request.CustomRoleId;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static CustomRoleDto ToDto(CustomRole r, int employeeCount) => new(
|
||||||
|
r.Id, r.Name, r.Description, r.Color,
|
||||||
|
CustomRolePermissions.Parse(r.PermissionsJson).Select(p => p.ToString()).OrderBy(p => p).ToList(),
|
||||||
|
employeeCount, r.CreatedAt);
|
||||||
|
|
||||||
|
private static IEnumerable<Permission> ParseAndValidatePermissions(IReadOnlyList<string>? names)
|
||||||
|
{
|
||||||
|
if (names is null) return [];
|
||||||
|
return names
|
||||||
|
.Where(n => Enum.TryParse<Permission>(n, ignoreCase: true, out _))
|
||||||
|
.Select(n => Enum.Parse<Permission>(n, ignoreCase: true))
|
||||||
|
.Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeColor(string? color)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(color)) return null;
|
||||||
|
var c = color.Trim();
|
||||||
|
return c.StartsWith('#') ? c : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Crm;
|
using Meezi.API.Models.Crm;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ public class CustomersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateCustomer) is { } permDenied) return permDenied;
|
||||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -77,6 +79,7 @@ public class CustomersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditCustomer) is { } permDenied) return permDenied;
|
||||||
var validation = await _updateValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _updateValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -99,6 +102,7 @@ public class CustomersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteCustomer) is { } permDenied) return permDenied;
|
||||||
var deleted = await _customerService.DeleteAsync(cafeId, id, cancellationToken);
|
var deleted = await _customerService.DeleteAsync(cafeId, id, cancellationToken);
|
||||||
if (!deleted) return NotFoundError();
|
if (!deleted) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id }));
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Services.Delivery;
|
using Meezi.API.Services.Delivery;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ public class DeliveryReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var utcTo = to ?? DateTime.UtcNow;
|
var utcTo = to ?? DateTime.UtcNow;
|
||||||
var utcFrom = from ?? utcTo.AddDays(-30);
|
var utcFrom = from ?? utcTo.AddDays(-30);
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ public class DemoSeedController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
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);
|
var result = await _demoSeed.SeedAsync(cafeId, ct);
|
||||||
return Ok(new ApiResponse<DemoSeedResult>(true, result));
|
return Ok(new ApiResponse<DemoSeedResult>(true, result));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Expenses;
|
using Meezi.API.Models.Expenses;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -30,14 +30,11 @@ public class ExpensesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateExpense) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrEmpty(tenant.UserId))
|
if (string.IsNullOrEmpty(tenant.UserId))
|
||||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||||
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
||||||
|
|
||||||
if (!CanLogExpense(tenant.Role))
|
|
||||||
return StatusCode(StatusCodes.Status403Forbidden,
|
|
||||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "You cannot log expenses.")));
|
|
||||||
|
|
||||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -57,6 +54,7 @@ public class ExpensesController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewExpenses) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(branchId))
|
if (string.IsNullOrWhiteSpace(branchId))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
@@ -85,10 +83,7 @@ public class ExpensesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteExpense) is { } permDenied) return permDenied;
|
||||||
if (!CanDeleteExpense(tenant.Role))
|
|
||||||
return StatusCode(StatusCodes.Status403Forbidden,
|
|
||||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Only managers can delete expenses.")));
|
|
||||||
|
|
||||||
var result = await _expenses.DeleteExpenseAsync(cafeId, id, ct);
|
var result = await _expenses.DeleteExpenseAsync(cafeId, id, ct);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
@@ -104,12 +99,6 @@ public class ExpensesController : CafeApiControllerBase
|
|||||||
return Ok(new ApiResponse<object>(true, null));
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool CanLogExpense(EmployeeRole? role) =>
|
|
||||||
role is EmployeeRole.Owner or EmployeeRole.Manager or EmployeeRole.Cashier;
|
|
||||||
|
|
||||||
private static bool CanDeleteExpense(EmployeeRole? role) =>
|
|
||||||
role is EmployeeRole.Owner or EmployeeRole.Manager;
|
|
||||||
|
|
||||||
private IActionResult ExpenseResult(ExpenseServiceResult<ExpenseDto> result, int successStatus = StatusCodes.Status200OK)
|
private IActionResult ExpenseResult(ExpenseServiceResult<ExpenseDto> result, int successStatus = StatusCodes.Status200OK)
|
||||||
{
|
{
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Hr;
|
using Meezi.API.Models.Hr;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Core.Utilities;
|
using Meezi.Core.Utilities;
|
||||||
@@ -42,10 +44,98 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewStaff) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.GetEmployeesAsync(cafeId, branchId, ct);
|
var data = await _hr.GetEmployeesAsync(cafeId, branchId, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
|
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 (EnsurePermission(tenant, Permission.CreateStaff) 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}")]
|
[HttpGet("employees/{employeeId}")]
|
||||||
public async Task<IActionResult> GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
public async Task<IActionResult> GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -95,6 +185,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewAttendance) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.GetAttendanceAsync(cafeId, employeeId, from, to, ct);
|
var data = await _hr.GetAttendanceAsync(cafeId, employeeId, from, to, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<AttendanceDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<AttendanceDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -103,6 +194,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> GetShifts(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
public async Task<IActionResult> GetShifts(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewSchedules) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.GetShiftsAsync(cafeId, employeeId, ct);
|
var data = await _hr.GetShiftsAsync(cafeId, employeeId, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -116,7 +208,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageSchedules) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.UpsertShiftsAsync(cafeId, employeeId, request, ct);
|
var data = await _hr.UpsertShiftsAsync(cafeId, employeeId, request, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -129,6 +221,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ReviewLeave) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.GetLeaveRequestsAsync(cafeId, status, ct);
|
var data = await _hr.GetLeaveRequestsAsync(cafeId, status, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<LeaveRequestDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<LeaveRequestDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -160,7 +253,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ReviewLeave) is { } forbidden) return forbidden;
|
||||||
var validation = await _reviewValidator.ValidateAsync(request, ct);
|
var validation = await _reviewValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -177,6 +270,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewSalaries) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.GetSalariesAsync(cafeId, monthYear, ct);
|
var data = await _hr.GetSalariesAsync(cafeId, monthYear, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<EmployeeSalaryDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<EmployeeSalaryDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -189,7 +283,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageSalaries) is { } forbidden) return forbidden;
|
||||||
var validation = await _salaryValidator.ValidateAsync(request, ct);
|
var validation = await _salaryValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -202,7 +296,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> MarkPaid(string cafeId, string salaryId, ITenantContext tenant, CancellationToken ct)
|
public async Task<IActionResult> MarkPaid(string cafeId, string salaryId, ITenantContext tenant, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageSalaries) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.MarkSalaryPaidAsync(cafeId, salaryId, ct);
|
var data = await _hr.MarkSalaryPaidAsync(cafeId, salaryId, ct);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
||||||
@@ -218,7 +312,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageStaffCredentials) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var username = request.Username.Trim().ToLowerInvariant();
|
var username = request.Username.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
@@ -256,7 +350,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageStaffCredentials) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var employee = await _db.Employees
|
var employee = await _db.Employees
|
||||||
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ public class InventoryController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateInventory) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required.")));
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required.")));
|
||||||
|
|
||||||
@@ -56,11 +58,26 @@ public class InventoryController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditInventory) is { } permDenied) return permDenied;
|
||||||
var updated = await _inventory.UpdateAsync(cafeId, ingredientId, request, ct);
|
var updated = await _inventory.UpdateAsync(cafeId, ingredientId, request, ct);
|
||||||
if (updated is null) return NotFoundError();
|
if (updated is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, updated));
|
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;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteInventory) is { } permDenied) return permDenied;
|
||||||
|
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")]
|
[HttpPost("ingredients/{ingredientId}/adjust")]
|
||||||
public async Task<IActionResult> Adjust(
|
public async Task<IActionResult> Adjust(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
@@ -70,6 +87,7 @@ public class InventoryController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditInventory) is { } permDenied) return permDenied;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var updated = await _inventory.AdjustAsync(cafeId, ingredientId, request, tenant.UserId, ct);
|
var updated = await _inventory.AdjustAsync(cafeId, ingredientId, request, tenant.UserId, ct);
|
||||||
@@ -133,6 +151,7 @@ public class InventoryController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditInventory) is { } permDenied) return permDenied;
|
||||||
var recipe = await _inventory.SetRecipeAsync(cafeId, menuItemId, request, ct);
|
var recipe = await _inventory.SetRecipeAsync(cafeId, menuItemId, request, ct);
|
||||||
if (recipe is null) return NotFoundError("Menu item not found.");
|
if (recipe is null) return NotFoundError("Menu item not found.");
|
||||||
return Ok(new ApiResponse<object>(true, recipe));
|
return Ok(new ApiResponse<object>(true, recipe));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Kitchen;
|
using Meezi.API.Models.Kitchen;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ public class KitchenStationsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
|
||||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ public class KitchenStationsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
|
||||||
var validation = await _updateValidator.ValidateAsync(request, ct);
|
var validation = await _updateValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -71,6 +74,7 @@ public class KitchenStationsController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> Delete(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
|
public async Task<IActionResult> Delete(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
|
||||||
var ok = await _stations.DeleteAsync(cafeId, id, ct);
|
var ok = await _stations.DeleteAsync(cafeId, id, ct);
|
||||||
if (!ok) return NotFoundError();
|
if (!ok) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id }));
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Infrastructure.Services.Platform;
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -18,13 +21,21 @@ public class MediaController : CafeApiControllerBase
|
|||||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadMenuImage(
|
public Task<IActionResult> UploadMenuImage(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveMenuImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return Upload(cafeId, file, tenant, _media.SaveMenuImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("menu-video")]
|
[HttpPost("menu-video")]
|
||||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadMenuVideo(
|
public Task<IActionResult> UploadMenuVideo(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveMenuVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return Upload(cafeId, file, tenant, _media.SaveMenuVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("menu-model3d")]
|
[HttpPost("menu-model3d")]
|
||||||
[RequestSizeLimit(8 * 1024 * 1024)]
|
[RequestSizeLimit(8 * 1024 * 1024)]
|
||||||
@@ -36,6 +47,7 @@ public class MediaController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "menu_3d", cancellationToken))
|
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "menu_3d", cancellationToken))
|
||||||
{
|
{
|
||||||
@@ -61,25 +73,68 @@ public class MediaController : CafeApiControllerBase
|
|||||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadTableImage(
|
public Task<IActionResult> UploadTableImage(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveTableImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return Upload(cafeId, file, tenant, _media.SaveTableImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("table-video")]
|
[HttpPost("table-video")]
|
||||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadTableVideo(
|
public Task<IActionResult> UploadTableVideo(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveTableVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return Upload(cafeId, file, tenant, _media.SaveTableVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("cafe-logo")]
|
[HttpPost("cafe-logo")]
|
||||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadCafeLogo(
|
public Task<IActionResult> UploadCafeLogo(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveCafeLogoAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return Upload(cafeId, file, tenant, _media.SaveCafeLogoAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("cafe-cover")]
|
[HttpPost("cafe-cover")]
|
||||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadCafeCover(
|
public Task<IActionResult> UploadCafeCover(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return 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(
|
private async Task<IActionResult> Upload(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
@@ -103,3 +158,12 @@ public class MediaController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record UploadResultDto(string Url);
|
public record UploadResultDto(string Url);
|
||||||
|
|
||||||
|
public record MediaAssetDto(
|
||||||
|
string Id,
|
||||||
|
string Url,
|
||||||
|
string Kind,
|
||||||
|
string ContentType,
|
||||||
|
long SizeBytes,
|
||||||
|
string? OriginalFileName,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Menu;
|
using Meezi.API.Models.Menu;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Constants;
|
using Meezi.Core.Constants;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -19,24 +21,27 @@ public class MenuController : CafeApiControllerBase
|
|||||||
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
||||||
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IPlatformCatalogService _catalog;
|
||||||
|
|
||||||
private const string CategoryLimitMessage =
|
private const string CategoryLimitMessage =
|
||||||
"محدودیت دستهبندی پلن رایگان (۳ دسته). برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
"به سقف دستهبندی منوی پلن شما رسیدید. برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
||||||
private const string ItemLimitMessage =
|
private const string ItemLimitMessage =
|
||||||
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
"به سقف آیتم منوی پلن شما رسیدید. برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
||||||
|
|
||||||
public MenuController(
|
public MenuController(
|
||||||
IMenuService menuService,
|
IMenuService menuService,
|
||||||
IMenuAi3dGenerationService menuAi3d,
|
IMenuAi3dGenerationService menuAi3d,
|
||||||
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
||||||
IValidator<CreateMenuItemRequest> createItemValidator,
|
IValidator<CreateMenuItemRequest> createItemValidator,
|
||||||
AppDbContext db)
|
AppDbContext db,
|
||||||
|
IPlatformCatalogService catalog)
|
||||||
{
|
{
|
||||||
_menuService = menuService;
|
_menuService = menuService;
|
||||||
_menuAi3d = menuAi3d;
|
_menuAi3d = menuAi3d;
|
||||||
_createCategoryValidator = createCategoryValidator;
|
_createCategoryValidator = createCategoryValidator;
|
||||||
_createItemValidator = createItemValidator;
|
_createItemValidator = createItemValidator;
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("categories")]
|
[HttpGet("categories")]
|
||||||
@@ -55,11 +60,12 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateMenuItem) is { } permDenied) return permDenied;
|
||||||
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
var max = PlanLimits.MaxMenuCategories(tier);
|
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuCategories;
|
||||||
if (max != int.MaxValue)
|
if (max != int.MaxValue)
|
||||||
{
|
{
|
||||||
var count = await _db.MenuCategories.CountAsync(
|
var count = await _db.MenuCategories.CountAsync(
|
||||||
@@ -82,6 +88,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
var data = await _menuService.UpdateCategoryAsync(cafeId, id, request, cancellationToken);
|
var data = await _menuService.UpdateCategoryAsync(cafeId, id, request, cancellationToken);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
||||||
@@ -91,6 +98,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> DeleteCategory(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
public async Task<IActionResult> DeleteCategory(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteMenuItem) is { } permDenied) return permDenied;
|
||||||
var deleted = await _menuService.DeleteCategoryAsync(cafeId, id, cancellationToken);
|
var deleted = await _menuService.DeleteCategoryAsync(cafeId, id, cancellationToken);
|
||||||
if (!deleted) return NotFoundError();
|
if (!deleted) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id }));
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
@@ -116,11 +124,12 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateMenuItem) is { } permDenied) return permDenied;
|
||||||
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
var max = PlanLimits.MaxMenuItems(tier);
|
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuItems;
|
||||||
if (max != int.MaxValue)
|
if (max != int.MaxValue)
|
||||||
{
|
{
|
||||||
var count = await _db.MenuItems.CountAsync(
|
var count = await _db.MenuItems.CountAsync(
|
||||||
@@ -144,6 +153,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
var data = await _menuService.UpdateItemAsync(cafeId, id, request, cancellationToken);
|
var data = await _menuService.UpdateItemAsync(cafeId, id, request, cancellationToken);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||||
@@ -158,11 +168,22 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
var data = await _menuService.SetAvailabilityAsync(cafeId, id, request.IsAvailable, cancellationToken);
|
var data = await _menuService.SetAvailabilityAsync(cafeId, id, request.IsAvailable, cancellationToken);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
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;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteMenuItem) is { } permDenied) return permDenied;
|
||||||
|
var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
|
||||||
|
if (!deleted) return NotFoundError();
|
||||||
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("ai-3d/usage")]
|
[HttpGet("ai-3d/usage")]
|
||||||
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -180,6 +201,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
var (data, code, message) = await _menuAi3d.GenerateFromItemImageAsync(cafeId, id, tier, cancellationToken);
|
var (data, code, message) = await _menuAi3d.GenerateFromItemImageAsync(cafeId, id, tier, cancellationToken);
|
||||||
if (code is not null)
|
if (code is not null)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Orders;
|
using Meezi.API.Models.Orders;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
@@ -20,6 +19,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
private readonly IValidator<RecordPaymentsRequest> _paymentsValidator;
|
private readonly IValidator<RecordPaymentsRequest> _paymentsValidator;
|
||||||
private readonly IValidator<AppendOrderItemsRequest> _appendValidator;
|
private readonly IValidator<AppendOrderItemsRequest> _appendValidator;
|
||||||
private readonly IValidator<UpdateOrderSessionRequest> _sessionValidator;
|
private readonly IValidator<UpdateOrderSessionRequest> _sessionValidator;
|
||||||
|
private readonly IValidator<CorrectPaymentsRequest> _correctionValidator;
|
||||||
|
|
||||||
public OrdersController(
|
public OrdersController(
|
||||||
IOrderService orderService,
|
IOrderService orderService,
|
||||||
@@ -28,7 +28,8 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
IValidator<UpdateOrderStatusRequest> statusValidator,
|
IValidator<UpdateOrderStatusRequest> statusValidator,
|
||||||
IValidator<RecordPaymentsRequest> paymentsValidator,
|
IValidator<RecordPaymentsRequest> paymentsValidator,
|
||||||
IValidator<AppendOrderItemsRequest> appendValidator,
|
IValidator<AppendOrderItemsRequest> appendValidator,
|
||||||
IValidator<UpdateOrderSessionRequest> sessionValidator)
|
IValidator<UpdateOrderSessionRequest> sessionValidator,
|
||||||
|
IValidator<CorrectPaymentsRequest> correctionValidator)
|
||||||
{
|
{
|
||||||
_orderService = orderService;
|
_orderService = orderService;
|
||||||
_audit = audit;
|
_audit = audit;
|
||||||
@@ -37,6 +38,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
_paymentsValidator = paymentsValidator;
|
_paymentsValidator = paymentsValidator;
|
||||||
_appendValidator = appendValidator;
|
_appendValidator = appendValidator;
|
||||||
_sessionValidator = sessionValidator;
|
_sessionValidator = sessionValidator;
|
||||||
|
_correctionValidator = correctionValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -63,6 +65,35 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
return Ok(new ApiResponse<IReadOnlyList<OrderDto>>(true, data));
|
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")]
|
[HttpGet("live")]
|
||||||
public async Task<IActionResult> GetLiveOrders(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
public async Task<IActionResult> GetLiveOrders(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -88,6 +119,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ProcessOrders) is { } permDenied) return permDenied;
|
||||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -107,6 +139,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
|
||||||
var validation = await _appendValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _appendValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -118,7 +151,6 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id}/items/{itemId}/void")]
|
[HttpPatch("{id}/items/{itemId}/void")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> VoidOrderItem(
|
public async Task<IActionResult> VoidOrderItem(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string id,
|
string id,
|
||||||
@@ -127,6 +159,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.VoidOrder) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrEmpty(tenant.UserId))
|
if (string.IsNullOrEmpty(tenant.UserId))
|
||||||
return StatusCode(StatusCodes.Status403Forbidden,
|
return StatusCode(StatusCodes.Status403Forbidden,
|
||||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User context required.")));
|
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User context required.")));
|
||||||
@@ -149,7 +182,6 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id}/transfer")]
|
[HttpPost("{id}/transfer")]
|
||||||
[Authorize(Roles = "Manager,Owner,Waiter")]
|
|
||||||
public async Task<IActionResult> TransferTable(
|
public async Task<IActionResult> TransferTable(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string id,
|
string id,
|
||||||
@@ -158,6 +190,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var result = await _orderService.TransferTableAsync(cafeId, id, request.TargetTableId, cancellationToken);
|
var result = await _orderService.TransferTableAsync(cafeId, id, request.TargetTableId, cancellationToken);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
@@ -175,6 +208,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
|
||||||
var validation = await _sessionValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _sessionValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -194,6 +228,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.UpdateOrderStatus) is { } permDenied) return permDenied;
|
||||||
var validation = await _statusValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _statusValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -211,7 +246,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsurePermission(tenant, Permission.ProcessOrders) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.VoidOrder) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var result = await _orderService.CancelOrderAsync(
|
var result = await _orderService.CancelOrderAsync(
|
||||||
cafeId, id, request.Reason, tenant.UserId, cancellationToken);
|
cafeId, id, request.Reason, tenant.UserId, cancellationToken);
|
||||||
@@ -247,6 +282,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.HandlePayments) is { } permDenied) return permDenied;
|
||||||
var validation = await _paymentsValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _paymentsValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -273,6 +309,56 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
return Ok(new ApiResponse<IReadOnlyList<PaymentDto>>(true, result.Data));
|
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 (EnsurePermission(tenant, Permission.ManageFinancials) 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) =>
|
private IActionResult OrderError(string code, string? field = null) =>
|
||||||
code switch
|
code switch
|
||||||
{
|
{
|
||||||
@@ -290,6 +376,8 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
false, null, new ApiError(code, "Order is already cancelled.", field))),
|
false, null, new ApiError(code, "Order is already cancelled.", field))),
|
||||||
"ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse<object>(
|
"ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse<object>(
|
||||||
false, null, new ApiError(code, "Refund the recorded payments before cancelling this order.", field))),
|
false, null, new ApiError(code, "Refund the recorded payments before cancelling this order.", field))),
|
||||||
|
"ORDER_IN_PREPARATION" => Conflict(new ApiResponse<object>(
|
||||||
|
false, null, new ApiError(code, "This order has already been sent to the kitchen and cannot be cancelled.", field))),
|
||||||
"ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>(
|
"ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>(
|
||||||
false, null, new ApiError(code, "Line item not found.", field))),
|
false, null, new ApiError(code, "Line item not found.", field))),
|
||||||
"ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>(
|
"ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>(
|
||||||
@@ -300,6 +388,10 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
false, null, new ApiError(code, "Table is being cleaned.", field))),
|
false, null, new ApiError(code, "Table is being cleaned.", field))),
|
||||||
"NO_OPEN_SHIFT" => BadRequest(new ApiResponse<object>(
|
"NO_OPEN_SHIFT" => BadRequest(new ApiResponse<object>(
|
||||||
false, null, new ApiError(code, "Open the cash register shift before taking payment.", field))),
|
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>(
|
_ => BadRequest(new ApiResponse<object>(
|
||||||
false, null, new ApiError(code, "Invalid order request.", field)))
|
false, null, new ApiError(code, "Invalid order request.", field)))
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Meezi.API.Models.Payments;
|
||||||
|
using Meezi.API.Services;
|
||||||
|
using Meezi.API.Services.Payments;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Shared;
|
||||||
|
|
||||||
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>FlatRender Pay (ZarinPal broker) checkout + webhook.</summary>
|
||||||
|
[ApiController]
|
||||||
|
public class PaymentController : CafeApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IBillingService _billing;
|
||||||
|
private readonly IFlatPayService _flatPay;
|
||||||
|
private readonly ILogger<PaymentController> _logger;
|
||||||
|
|
||||||
|
public PaymentController(IBillingService billing, IFlatPayService flatPay, ILogger<PaymentController> logger)
|
||||||
|
{
|
||||||
|
_billing = billing;
|
||||||
|
_flatPay = flatPay;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Start a FlatPay checkout for a plan bundle; returns the URL to redirect the buyer to.</summary>
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("api/payment/request")]
|
||||||
|
public async Task<IActionResult> CreatePayment(
|
||||||
|
[FromBody] PaymentRequestDto request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
|
||||||
|
if (string.IsNullOrEmpty(tenant.CafeId)) return Unauthorized();
|
||||||
|
|
||||||
|
if (request?.ProductId is null || !TryParseProduct(request.ProductId, out var tier, out var months))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("INVALID_PRODUCT", "productId must be a \"Tier:Months\" bundle, e.g. \"Pro:12\".")));
|
||||||
|
|
||||||
|
var (paymentId, amountToman, code, message) =
|
||||||
|
await _billing.CreateFlatPayOrderAsync(tenant.CafeId, tier, months, ct);
|
||||||
|
if (paymentId is null)
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||||
|
|
||||||
|
var description = $"میزی — اشتراک {tier} ({months} ماه)";
|
||||||
|
var url = await _flatPay.RequestAsync(
|
||||||
|
tenant.CafeId, request.ProductId, (long)amountToman, description, paymentId, ct);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(url))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("PAYMENT_FAILED", "Could not start the payment.")));
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<PaymentRequestResponse>(true, new PaymentRequestResponse(url, paymentId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Broker → us. Security is the HMAC signature (no user auth). Always 200 after a valid
|
||||||
|
/// signature so the broker doesn't retry a job we've accepted.</summary>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("api/payment/webhook")]
|
||||||
|
public async Task<IActionResult> Webhook(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await Request.Body.CopyToAsync(ms, ct);
|
||||||
|
var raw = ms.ToArray();
|
||||||
|
|
||||||
|
var signature = Request.Headers["X-FlatPay-Signature"].ToString();
|
||||||
|
if (!_flatPay.VerifyWebhook(raw, signature))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(raw);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var status = GetString(root, "status");
|
||||||
|
var brokerId = GetString(root, "id") ?? GetString(root, "payment_id");
|
||||||
|
|
||||||
|
if (string.Equals(status, "Paid", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.IsNullOrEmpty(brokerId)
|
||||||
|
&& _flatPay.TryMarkProcessed(brokerId))
|
||||||
|
{
|
||||||
|
var meta = root.TryGetProperty("metadata", out var m) && m.ValueKind == JsonValueKind.Object
|
||||||
|
? m
|
||||||
|
: default;
|
||||||
|
var paymentId = GetString(meta, "payment_id");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(paymentId))
|
||||||
|
await _billing.CompleteFlatPayAsync(paymentId, brokerId, ct);
|
||||||
|
else
|
||||||
|
_logger.LogWarning("FlatPay webhook Paid but missing metadata.payment_id (broker id {Id})", brokerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "FlatPay webhook processing error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parse a "Tier:Months" product id, e.g. "Pro:12" → (PlanTier.Pro, 12).</summary>
|
||||||
|
private static bool TryParseProduct(string productId, out PlanTier tier, out int months)
|
||||||
|
{
|
||||||
|
tier = default;
|
||||||
|
months = 0;
|
||||||
|
var parts = productId.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length != 2) return false;
|
||||||
|
return Enum.TryParse(parts[0], ignoreCase: true, out tier)
|
||||||
|
&& tier != PlanTier.Free
|
||||||
|
&& int.TryParse(parts[1], out months)
|
||||||
|
&& months > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement el, string name)
|
||||||
|
{
|
||||||
|
if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var v))
|
||||||
|
return null;
|
||||||
|
return v.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => v.GetString(),
|
||||||
|
JsonValueKind.Number => v.ToString(),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Printing;
|
using Meezi.API.Models.Printing;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
[Route("api/cafes/{cafeId}/branches/{branchId}/pos-device")]
|
[Route("api/cafes/{cafeId}/branches/{branchId}/pos-device")]
|
||||||
[Authorize(Roles = "Cashier,Manager,Owner")]
|
|
||||||
public class PosDeviceController : CafeApiControllerBase
|
public class PosDeviceController : CafeApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IPosDeviceService _posDevice;
|
private readonly IPosDeviceService _posDevice;
|
||||||
@@ -30,6 +29,7 @@ public class PosDeviceController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.HandlePayments) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var validation = await _validator.ValidateAsync(request, ct);
|
var validation = await _validator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Meezi.API.Hubs;
|
||||||
|
using Meezi.API.Models.Printing;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Meezi.Shared;
|
||||||
|
|
||||||
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anonymous endpoint the print-agent installer calls to redeem a pairing code for
|
||||||
|
/// a long-lived token. The token is returned exactly once; only its hash is stored.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("api/print-agent")]
|
||||||
|
public class PrintAgentPairingController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public PrintAgentPairingController(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
[HttpPost("claim")]
|
||||||
|
public async Task<IActionResult> Claim([FromBody] ClaimAgentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Code))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError("CODE_REQUIRED", "Pairing code is required.")));
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var agent = await _db.PrintAgents
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(a =>
|
||||||
|
a.PairingCode == request.Code &&
|
||||||
|
a.TokenHash == null &&
|
||||||
|
!a.Revoked &&
|
||||||
|
a.DeletedAt == null &&
|
||||||
|
a.PairingCodeExpiresAt > now, ct);
|
||||||
|
|
||||||
|
if (agent is null)
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("INVALID_OR_EXPIRED_CODE", "This pairing code is invalid or has expired.")));
|
||||||
|
|
||||||
|
var token = NewToken();
|
||||||
|
agent.TokenHash = PrintAgentHub.HashToken(token);
|
||||||
|
agent.PairingCode = null;
|
||||||
|
agent.PairingCodeExpiresAt = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name)) agent.Name = request.Name!.Trim();
|
||||||
|
else if (!string.IsNullOrWhiteSpace(request.MachineName)) agent.Name = request.MachineName!.Trim();
|
||||||
|
agent.LastSeenAt = now;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<ClaimAgentResponse>(
|
||||||
|
true, new ClaimAgentResponse(agent.Id, token, agent.CafeId, agent.Name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NewToken()
|
||||||
|
{
|
||||||
|
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||||
|
return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Meezi.API.Hubs;
|
||||||
|
using Meezi.API.Models.Printing;
|
||||||
|
using Meezi.API.Services.Printing;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
|
using Meezi.Core.Entities;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Meezi.Shared;
|
||||||
|
|
||||||
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Manage the local print agents paired to a café (cloud → LAN bridge).</summary>
|
||||||
|
[Route("api/cafes/{cafeId}/print-agents")]
|
||||||
|
public class PrintAgentsController : CafeApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IPrintAgentRegistry _registry;
|
||||||
|
private readonly IPrinterService _printer;
|
||||||
|
|
||||||
|
public PrintAgentsController(AppDbContext db, IPrintAgentRegistry registry, IPrinterService printer)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_registry = registry;
|
||||||
|
_printer = printer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewPrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
var agents = await _db.PrintAgents
|
||||||
|
.Where(a => a.CafeId == cafeId)
|
||||||
|
.Include(a => a.Devices)
|
||||||
|
.OrderBy(a => a.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var dtos = agents.Select(a => new PrintAgentDto(
|
||||||
|
a.Id,
|
||||||
|
a.Name,
|
||||||
|
a.BranchId,
|
||||||
|
_registry.IsOnline(a.Id),
|
||||||
|
a.TokenHash is not null,
|
||||||
|
a.LastSeenAt,
|
||||||
|
a.CreatedAt,
|
||||||
|
a.Devices
|
||||||
|
.OrderBy(d => d.DisplayName)
|
||||||
|
.Select(d => new PrintAgentDeviceDto(d.Id, d.SystemName, d.DisplayName, d.Kind, d.LastSeenAt))
|
||||||
|
.ToList()
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<IReadOnlyList<PrintAgentDto>>(true, dtos));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Create a pending agent and a short one-time code the installer enters to pair.</summary>
|
||||||
|
[HttpPost("pairing-code")]
|
||||||
|
public async Task<IActionResult> CreatePairingCode(
|
||||||
|
string cafeId,
|
||||||
|
[FromBody] CreatePairingCodeRequest request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
var code = await GenerateUniqueCodeAsync(ct);
|
||||||
|
var agent = new PrintAgent
|
||||||
|
{
|
||||||
|
CafeId = cafeId,
|
||||||
|
BranchId = string.IsNullOrWhiteSpace(request.BranchId) ? null : request.BranchId,
|
||||||
|
Name = string.IsNullOrWhiteSpace(request.Name) ? "پرینتسرور" : request.Name!.Trim(),
|
||||||
|
PairingCode = code,
|
||||||
|
PairingCodeExpiresAt = DateTime.UtcNow.AddMinutes(15),
|
||||||
|
};
|
||||||
|
_db.PrintAgents.Add(agent);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<PairingCodeResponse>(
|
||||||
|
true, new PairingCodeResponse(agent.Id, code, agent.PairingCodeExpiresAt!.Value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Unpair/revoke an agent — it can no longer connect or print.</summary>
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> Revoke(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
var agent = await _db.PrintAgents.FirstOrDefaultAsync(a => a.Id == id && a.CafeId == cafeId, ct);
|
||||||
|
if (agent is null)
|
||||||
|
return NotFound(new ApiResponse<object>(false, null, new ApiError("AGENT_NOT_FOUND", "Print agent not found.")));
|
||||||
|
|
||||||
|
agent.Revoked = true;
|
||||||
|
agent.TokenHash = null;
|
||||||
|
agent.PairingCode = null;
|
||||||
|
agent.DeletedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Send a test page to a discovered printer through its agent.</summary>
|
||||||
|
[HttpPost("devices/{deviceId}/test")]
|
||||||
|
public async Task<IActionResult> TestDevice(string cafeId, string deviceId, ITenantContext tenant, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
var result = await _printer.TestPrintDeviceAsync(cafeId, deviceId, ct);
|
||||||
|
return result.Success
|
||||||
|
? Ok(new ApiResponse<object>(true, null))
|
||||||
|
: BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError(result.ErrorCode ?? "PRINT_FAILED", result.ErrorDetail ?? "Test print failed.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ask the café's online agents to scan their LAN for devices (network
|
||||||
|
/// printers on :9100, card terminals on :8088) so the owner can pick instead of
|
||||||
|
/// typing an IP. Merges results across agents.</summary>
|
||||||
|
[HttpPost("scan")]
|
||||||
|
public async Task<IActionResult> Scan(
|
||||||
|
string cafeId,
|
||||||
|
[FromBody] ScanRequest request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
var online = _registry.OnlineAgentIdsForCafe(cafeId);
|
||||||
|
if (online.Count == 0)
|
||||||
|
return BadRequest(new ApiResponse<object>(
|
||||||
|
false, null, new ApiError("AGENT_OFFLINE", "No print agent is online to scan the network.")));
|
||||||
|
|
||||||
|
var ports = string.IsNullOrWhiteSpace(request.Ports) ? "9100,8088" : request.Ports!.Trim();
|
||||||
|
var merged = new Dictionary<string, ScannedDeviceDto>();
|
||||||
|
foreach (var agentId in online)
|
||||||
|
{
|
||||||
|
foreach (var d in await _registry.ScanAsync(agentId, ports, ct))
|
||||||
|
merged[$"{d.Ip}:{d.Port}"] = new ScannedDeviceDto(d.Ip, d.Port, d.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dtos = merged.Values.OrderBy(d => d.Ip).ThenBy(d => d.Port).ToList();
|
||||||
|
return Ok(new ApiResponse<IReadOnlyList<ScannedDeviceDto>>(true, dtos));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateUniqueCodeAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; attempt < 8; attempt++)
|
||||||
|
{
|
||||||
|
var code = RandomNumberGenerator.GetInt32(100_000, 1_000_000).ToString();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var clash = await _db.PrintAgents.IgnoreQueryFilters().AnyAsync(
|
||||||
|
a => a.PairingCode == code && a.PairingCodeExpiresAt > now && a.TokenHash == null, ct);
|
||||||
|
if (!clash) return code;
|
||||||
|
}
|
||||||
|
// Extremely unlikely; fall back to a longer code.
|
||||||
|
return RandomNumberGenerator.GetInt32(100_000, 1_000_000).ToString() +
|
||||||
|
RandomNumberGenerator.GetInt32(10, 100).ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Printing;
|
using Meezi.API.Models.Printing;
|
||||||
using Meezi.API.Services.Printing;
|
using Meezi.API.Services.Printing;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -32,16 +32,18 @@ public class PrintController : CafeApiControllerBase
|
|||||||
string cafeId,
|
string cafeId,
|
||||||
string orderId,
|
string orderId,
|
||||||
ITenantContext tenant,
|
ITenantContext tenant,
|
||||||
CancellationToken ct)
|
CancellationToken ct,
|
||||||
|
[FromQuery] string? stationId)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
|
||||||
var result = await _printer.PrintKitchenTicketAsync(cafeId, orderId, ct);
|
// stationId omitted → print every station (kitchen + bar …); provided →
|
||||||
|
// reprint only that one station's items.
|
||||||
|
var result = await _printer.PrintKitchenTicketAsync(cafeId, orderId, stationId, ct);
|
||||||
return ToActionResult(result);
|
return ToActionResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("test")]
|
[HttpPost("test")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> TestPrint(
|
public async Task<IActionResult> TestPrint(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
[FromBody] TestPrintRequest request,
|
[FromBody] TestPrintRequest request,
|
||||||
@@ -49,6 +51,7 @@ public class PrintController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var result = await _printer.TestPrintAsync(request.PrinterIp, request.Port, ct);
|
var result = await _printer.TestPrintAsync(request.PrinterIp, request.Port, ct);
|
||||||
return ToActionResult(result);
|
return ToActionResult(result);
|
||||||
@@ -62,7 +65,8 @@ public class PrintController : CafeApiControllerBase
|
|||||||
|
|
||||||
var status = result.ErrorCode switch
|
var status = result.ErrorCode switch
|
||||||
{
|
{
|
||||||
"PRINTER_NOT_CONFIGURED" or "KITCHEN_PRINTER_NOT_CONFIGURED" => StatusCodes.Status400BadRequest,
|
"PRINTER_NOT_CONFIGURED" or "KITCHEN_PRINTER_NOT_CONFIGURED" or "NO_STATION_ITEMS"
|
||||||
|
=> StatusCodes.Status400BadRequest,
|
||||||
"ORDER_NOT_FOUND" => StatusCodes.Status404NotFound,
|
"ORDER_NOT_FOUND" => StatusCodes.Status404NotFound,
|
||||||
_ => StatusCodes.Status502BadGateway
|
_ => StatusCodes.Status502BadGateway
|
||||||
};
|
};
|
||||||
@@ -75,6 +79,7 @@ public class PrintController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
"PRINTER_NOT_CONFIGURED" => "Receipt printer IP is not configured for this branch.",
|
"PRINTER_NOT_CONFIGURED" => "Receipt printer IP is not configured for this branch.",
|
||||||
"KITCHEN_PRINTER_NOT_CONFIGURED" => "Kitchen printer IP is not configured for this branch.",
|
"KITCHEN_PRINTER_NOT_CONFIGURED" => "Kitchen printer IP is not configured for this branch.",
|
||||||
|
"NO_STATION_ITEMS" => "This order has no items for the selected station.",
|
||||||
"PRINTER_CONNECTION_FAILED" => "Could not connect to the printer.",
|
"PRINTER_CONNECTION_FAILED" => "Could not connect to the printer.",
|
||||||
"ORDER_NOT_FOUND" => "Order not found.",
|
"ORDER_NOT_FOUND" => "Order not found.",
|
||||||
_ => "Print failed."
|
_ => "Print failed."
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -13,19 +16,19 @@ namespace Meezi.API.Controllers;
|
|||||||
///
|
///
|
||||||
/// POST /api/public/push/register — anonymous device registration
|
/// POST /api/public/push/register — anonymous device registration
|
||||||
/// POST /api/public/push/unregister — anonymous device removal
|
/// POST /api/public/push/unregister — anonymous device removal
|
||||||
/// POST /api/push/broadcast — authorized topic broadcast (marketing /
|
/// POST /api/push/broadcast — café marketing push (own topic only)
|
||||||
/// saved-café alerts)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
public class PushController : CafeApiControllerBase
|
||||||
public class PushController : ControllerBase
|
|
||||||
{
|
{
|
||||||
private readonly IPushDeviceService _devices;
|
private readonly IPushDeviceService _devices;
|
||||||
private readonly IPushSender _sender;
|
private readonly IPushSender _sender;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
public PushController(IPushDeviceService devices, IPushSender sender)
|
public PushController(IPushDeviceService devices, IPushSender sender, AppDbContext db)
|
||||||
{
|
{
|
||||||
_devices = devices;
|
_devices = devices;
|
||||||
_sender = sender;
|
_sender = sender;
|
||||||
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/public/push/register")]
|
[HttpPost("api/public/push/register")]
|
||||||
@@ -53,15 +56,26 @@ public class PushController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/push/broadcast")]
|
[HttpPost("api/push/broadcast")]
|
||||||
[Authorize]
|
|
||||||
public async Task<IActionResult> Broadcast(
|
public async Task<IActionResult> Broadcast(
|
||||||
[FromBody] BroadcastPushRequest request, CancellationToken ct)
|
[FromBody] BroadcastPushRequest request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Topic))
|
if (EnsurePermission(tenant, Permission.SendSms) is { } forbidden) return forbidden;
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||||
new ApiError("INVALID_TOPIC", "Topic is required.")));
|
return StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Café context is required.")));
|
||||||
|
|
||||||
await _sender.SendToTopicAsync(request.Topic, request.Title, request.Body, request.DeepLink, ct);
|
// A café may only push to its OWN topic (cafe-{slug}). The client-supplied
|
||||||
|
// topic is intentionally ignored to prevent cross-café / city-wide pushes.
|
||||||
|
var slug = await _db.Cafes.AsNoTracking()
|
||||||
|
.Where(c => c.Id == tenant.CafeId)
|
||||||
|
.Select(c => c.Slug)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(slug))
|
||||||
|
return NotFoundError("Café not found.");
|
||||||
|
|
||||||
|
await _sender.SendToTopicAsync($"cafe-{slug}", request.Title, request.Body, request.DeepLink, ct);
|
||||||
return Ok(new ApiResponse<object>(true, new { sent = true }));
|
return Ok(new ApiResponse<object>(true, new { sent = true }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Queue;
|
using Meezi.API.Models.Queue;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -37,6 +38,7 @@ public class QueueController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageQueue) is { } permDenied) return permDenied;
|
||||||
var (ticket, error) = await _queue.IssueNextAsync(cafeId, tenant.UserId, request, ct);
|
var (ticket, error) = await _queue.IssueNextAsync(cafeId, tenant.UserId, request, ct);
|
||||||
if (error == "BRANCH_NOT_FOUND")
|
if (error == "BRANCH_NOT_FOUND")
|
||||||
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Branch not found.")));
|
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Branch not found.")));
|
||||||
@@ -54,6 +56,7 @@ public class QueueController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageQueue) is { } permDenied) return permDenied;
|
||||||
var (ticket, error) = await _queue.UpdateStatusAsync(cafeId, ticketId, request.Status, ct);
|
var (ticket, error) = await _queue.UpdateStatusAsync(cafeId, ticketId, request.Status, ct);
|
||||||
if (error == "NOT_FOUND")
|
if (error == "NOT_FOUND")
|
||||||
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Ticket not found.")));
|
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Ticket not found.")));
|
||||||
@@ -71,6 +74,7 @@ public class QueueController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageQueue) is { } permDenied) return permDenied;
|
||||||
var board = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
|
var board = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
|
||||||
var next = board.Tickets.FirstOrDefault(t => t.Status == QueueTicketStatus.Waiting);
|
var next = board.Tickets.FirstOrDefault(t => t.Status == QueueTicketStatus.Waiting);
|
||||||
if (next is null)
|
if (next is null)
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Meezi.API.Models.Reports;
|
using Meezi.API.Models.Reports;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.API.Utils;
|
using Meezi.API.Utils;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -13,13 +15,21 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IReportService _reports;
|
private readonly IReportService _reports;
|
||||||
private readonly IDailyReportService _dailyReports;
|
private readonly IDailyReportService _dailyReports;
|
||||||
|
private readonly IPlatformCatalogService _catalog;
|
||||||
|
|
||||||
public ReportsController(IReportService reports, IDailyReportService dailyReports)
|
public ReportsController(
|
||||||
|
IReportService reports,
|
||||||
|
IDailyReportService dailyReports,
|
||||||
|
IPlatformCatalogService catalog)
|
||||||
{
|
{
|
||||||
_reports = reports;
|
_reports = reports;
|
||||||
_dailyReports = dailyReports;
|
_dailyReports = dailyReports;
|
||||||
|
_catalog = catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<int> MaxHistoryDaysAsync(ITenantContext tenant, CancellationToken ct) =>
|
||||||
|
(await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxReportHistoryDays;
|
||||||
|
|
||||||
[HttpGet("daily")]
|
[HttpGet("daily")]
|
||||||
public async Task<IActionResult> GetDailySnapshot(
|
public async Task<IActionResult> GetDailySnapshot(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
@@ -29,6 +39,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrWhiteSpace(branchId))
|
if (string.IsNullOrWhiteSpace(branchId))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
|
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
|
||||||
@@ -37,7 +48,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "Invalid date. Use yyyy-MM-dd.", "date")));
|
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);
|
var snapshot = await _dailyReports.GetReportAsync(cafeId, branchId, reportDate, ct);
|
||||||
if (snapshot is null)
|
if (snapshot is null)
|
||||||
@@ -56,22 +67,23 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
if (!TryParseReportDate(from, out var startDate) || !TryParseReportDate(to, out var endDate))
|
if (!TryParseReportDate(from, out var startDate) || !TryParseReportDate(to, out var endDate))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
|
new ApiError("VALIDATION_ERROR", "Invalid from/to. Use yyyy-MM-dd.", "from")));
|
||||||
|
|
||||||
var today = IranCalendar.TodayInIran;
|
var today = IranCalendar.TodayInIran;
|
||||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||||
|
|
||||||
if (!ReportPlanGate.IsDateInRange(tier, startDate, today)
|
if (!ReportPlanGate.IsDateInRange(maxDays, startDate, today)
|
||||||
|| !ReportPlanGate.IsDateInRange(tier, endDate, today))
|
|| !ReportPlanGate.IsDateInRange(maxDays, endDate, today))
|
||||||
{
|
{
|
||||||
return StatusCode(403, new ApiResponse<object>(false, 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")));
|
||||||
}
|
}
|
||||||
|
|
||||||
var clamped = ReportPlanGate.ClampRange(tier, startDate, endDate, today);
|
var clamped = ReportPlanGate.ClampRange(maxDays, startDate, endDate, today);
|
||||||
if (clamped is null)
|
if (clamped is null)
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "Invalid date range.", "from")));
|
new ApiError("VALIDATION_ERROR", "Invalid date range.", "from")));
|
||||||
@@ -90,13 +102,13 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||||
var maxDays = Core.Constants.PlanLimits.MaxReportHistoryDays(tier);
|
|
||||||
if (days > maxDays && maxDays != int.MaxValue)
|
if (days > maxDays && maxDays != int.MaxValue)
|
||||||
{
|
{
|
||||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
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);
|
days = Math.Min(days, maxDays == int.MaxValue ? 365 : maxDays);
|
||||||
@@ -112,6 +124,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
if (date is not null && !JalaliCalendarHelper.TryParseJalaliDate(date, out _, out _, out _))
|
if (date is not null && !JalaliCalendarHelper.TryParseJalaliDate(date, out _, out _, out _))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali date. Use yyyy-MM-dd.")));
|
new ApiError("VALIDATION_ERROR", "Invalid Jalali date. Use yyyy-MM-dd.")));
|
||||||
@@ -128,6 +141,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
if (month is not null && !JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
|
if (month is not null && !JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
|
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
|
||||||
@@ -144,6 +158,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
var data = await _reports.GetTrendAsync(cafeId, days, ct);
|
var data = await _reports.GetTrendAsync(cafeId, days, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<TrendDayDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<TrendDayDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -157,6 +172,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ExportReports) is { } permDenied) return permDenied;
|
||||||
if (!string.Equals(format, "excel", StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(format, "excel", StringComparison.OrdinalIgnoreCase))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "Only excel format is supported.")));
|
new ApiError("VALIDATION_ERROR", "Only excel format is supported.")));
|
||||||
@@ -180,14 +196,14 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
return DateOnly.TryParse(value, out date);
|
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;
|
var today = IranCalendar.TodayInIran;
|
||||||
if (ReportPlanGate.IsDateInRange(tier, date, today))
|
if (ReportPlanGate.IsDateInRange(maxDays, date, today))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return StatusCode(403, new ApiResponse<object>(false, 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")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -30,6 +31,7 @@ public class ReservationsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateReservation) is { } permDenied) return permDenied;
|
||||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -62,10 +64,25 @@ public class ReservationsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditReservation) is { } permDenied) return permDenied;
|
||||||
var data = await _reservations.UpdateStatusAsync(cafeId, id, request.Status, ct);
|
var data = await _reservations.UpdateStatusAsync(cafeId, id, request.Status, ct);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<ReservationDto>(true, data));
|
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;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteReservation) is { } permDenied) return permDenied;
|
||||||
|
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);
|
public record UpdateReservationStatusRequest(ReservationStatus Status);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Shifts;
|
using Meezi.API.Models.Shifts;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ public class ShiftsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.OperateRegister) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrEmpty(tenant.UserId))
|
if (string.IsNullOrEmpty(tenant.UserId))
|
||||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||||
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
||||||
@@ -54,6 +56,7 @@ public class ShiftsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.OperateRegister) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrEmpty(tenant.UserId))
|
if (string.IsNullOrEmpty(tenant.UserId))
|
||||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||||
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
||||||
|
|||||||
@@ -2,38 +2,70 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Crm;
|
using Meezi.API.Models.Crm;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
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")]
|
[Route("api/cafes/{cafeId}/sms")]
|
||||||
public class SmsController : CafeApiControllerBase
|
public class SmsController : CafeApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ISmsMarketingService _smsMarketingService;
|
private readonly ISmsMarketingService _smsMarketingService;
|
||||||
private readonly ISmsService _smsService;
|
|
||||||
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
|
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
|
||||||
|
|
||||||
public SmsController(
|
public SmsController(
|
||||||
ISmsMarketingService smsMarketingService,
|
ISmsMarketingService smsMarketingService,
|
||||||
ISmsService smsService,
|
|
||||||
IValidator<SendSmsCampaignRequest> campaignValidator)
|
IValidator<SendSmsCampaignRequest> campaignValidator)
|
||||||
{
|
{
|
||||||
_smsMarketingService = smsMarketingService;
|
_smsMarketingService = smsMarketingService;
|
||||||
_smsService = smsService;
|
|
||||||
_campaignValidator = campaignValidator;
|
_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 (EnsurePermission(tenant, Permission.ManageSmsSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
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")]
|
[HttpGet("balance")]
|
||||||
public async Task<IActionResult> GetBalance(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
public async Task<IActionResult> GetBalance(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
|
||||||
var info = await _smsService.GetAccountInfoAsync(cancellationToken);
|
var dto = await _smsMarketingService.GetBalanceAsync(cafeId, cancellationToken);
|
||||||
var dto = info is not null
|
|
||||||
? new SmsBalanceDto(info.RemainCredit, info.AccountType, true)
|
|
||||||
: new SmsBalanceDto(0, "master", false);
|
|
||||||
|
|
||||||
return Ok(new ApiResponse<SmsBalanceDto>(true, dto));
|
return Ok(new ApiResponse<SmsBalanceDto>(true, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +73,8 @@ public class SmsController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
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));
|
return Ok(new ApiResponse<SmsUsageDto>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,20 +86,19 @@ public class SmsController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (tenant.PlanTier is null)
|
if (EnsurePermission(tenant, Permission.SendSms) is { } permDenied) return permDenied;
|
||||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
|
|
||||||
|
|
||||||
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
var (success, data, code, message) = await _smsMarketingService.SendCampaignAsync(
|
var (success, data, code, message) = await _smsMarketingService.SendCampaignAsync(
|
||||||
cafeId, tenant.PlanTier.Value, request, cancellationToken);
|
cafeId, request, cancellationToken);
|
||||||
|
|
||||||
if (!success)
|
if (!success)
|
||||||
{
|
{
|
||||||
return code switch
|
return code switch
|
||||||
{
|
{
|
||||||
"PLAN_LIMIT_REACHED" => StatusCode(StatusCodes.Status403Forbidden,
|
"SMS_NOT_CONFIGURED" => BadRequest(
|
||||||
new ApiResponse<object>(false, null, new ApiError(code, message!))),
|
new ApiResponse<object>(false, null, new ApiError(code, message!))),
|
||||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))),
|
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))),
|
||||||
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!)))
|
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!)))
|
||||||
|
|||||||