Compare commits
79 Commits
5078af2dd7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 352c3b41cb | |||
| 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 |
@@ -6,6 +6,20 @@
|
||||
"runtimeExecutable": "dotnet",
|
||||
"runtimeArgs": ["run", "--project", "F:/Projects/DrSousan/DrSousan.Api", "--urls", "http://localhost:5000"],
|
||||
"port": 5000
|
||||
},
|
||||
{
|
||||
"name": "meezi-website",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["node_modules/next/dist/bin/next", "dev", "-p", "3013"],
|
||||
"cwd": "web/website",
|
||||
"port": 3013
|
||||
},
|
||||
{
|
||||
"name": "meezi-dashboard",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["node_modules/next/dist/bin/next", "dev", "-p", "3015"],
|
||||
"cwd": "web/dashboard",
|
||||
"port": 3015
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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_ADMIN_API_URL=http://171.22.25.73:5081
|
||||
NEXT_PUBLIC_SITE_URL=http://171.22.25.73:3010
|
||||
NEXT_PUBLIC_KOJA_URL=http://171.22.25.73:3103
|
||||
# Public site origin — MUST be the real domain in prod (used for canonical URLs,
|
||||
# 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
|
||||
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_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 ────────────────────────────────────────────────────────────
|
||||
# Empty = OTP is logged to API console (fine for dev, not for production)
|
||||
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>
|
||||
EOF
|
||||
|
||||
- name: Verify mirror TLS chain
|
||||
# The mirror's fullchain.pem now serves leaf → YR2 → ISRG Root YR
|
||||
# (cross-signed by ISRG Root X1, which IS in every stock trust store),
|
||||
# so no custom CA is needed. This step only sanity-checks the chain and
|
||||
# fails early with a clear message if the server cert regresses again.
|
||||
# POSIX sh only — the Gitea act runner v0.6.1 ignores shell: overrides.
|
||||
run: |
|
||||
set -eu
|
||||
echo | openssl s_client -connect mirror.soroushasadi.com:443 \
|
||||
-servername mirror.soroushasadi.com 2>/dev/null \
|
||||
| tee /tmp/sclient.txt | grep "Verify return code" || true
|
||||
if ! grep -q "Verify return code: 0 (ok)" /tmp/sclient.txt; then
|
||||
echo "❌ mirror.soroushasadi.com TLS chain is broken again."
|
||||
echo " Fix the cert ON THE SERVER (/etc/ssl/soroushasadi/fullchain.pem"
|
||||
echo " must include the full chain up to a publicly-trusted root),"
|
||||
echo " then: docker exec mirror-nginx nginx -s reload"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore src/Meezi.API/Meezi.API.csproj --configfile /tmp/nuget.ci.config
|
||||
env:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
||||
NUGET_CERT_REVOCATION_MODE: offline
|
||||
|
||||
- name: Build
|
||||
run: dotnet build src/Meezi.API/Meezi.API.csproj --no-restore -c Release
|
||||
@@ -128,10 +148,23 @@ jobs:
|
||||
</configuration>
|
||||
EOF
|
||||
|
||||
- name: Verify mirror TLS chain
|
||||
# Same sanity check as api-build — see that job for full comments.
|
||||
run: |
|
||||
set -eu
|
||||
echo | openssl s_client -connect mirror.soroushasadi.com:443 \
|
||||
-servername mirror.soroushasadi.com 2>/dev/null \
|
||||
| tee /tmp/sclient.txt | grep "Verify return code" || true
|
||||
if ! grep -q "Verify return code: 0 (ok)" /tmp/sclient.txt; then
|
||||
echo "❌ mirror.soroushasadi.com TLS chain is broken again — fix the server cert."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore src/Meezi.Admin.API/Meezi.Admin.API.csproj --configfile /tmp/nuget.ci.config
|
||||
env:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: 1
|
||||
NUGET_CERT_REVOCATION_MODE: offline
|
||||
|
||||
- name: Build
|
||||
run: dotnet build src/Meezi.Admin.API/Meezi.Admin.API.csproj --no-restore -c Release
|
||||
@@ -413,6 +446,11 @@ jobs:
|
||||
-f docker-compose.admin.yml \
|
||||
up -d --no-deps admin-web
|
||||
|
||||
- name: Start nightly DB backup
|
||||
# Sidecar that pg_dumps meezi-db nightly into ./backups (14-day retention).
|
||||
# --no-deps so it doesn't try to (re)start postgres which isn't compose-managed.
|
||||
run: docker compose up -d --no-deps backup
|
||||
|
||||
- name: Show all running containers
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
# Domains needed in DNS (all → same server IP):
|
||||
# meezi.ir, app.meezi.ir, api.meezi.ir,
|
||||
# koja.meezi.ir, admin.meezi.ir, admin-api.meezi.ir
|
||||
# status.meezi.ir (only if the monitoring stack is running — see docs/monitoring.md)
|
||||
|
||||
{
|
||||
email {$ACME_EMAIL}
|
||||
@@ -41,3 +42,10 @@ admin.{$DOMAIN} {
|
||||
admin-api.{$DOMAIN} {
|
||||
reverse_proxy admin-api:8080
|
||||
}
|
||||
|
||||
# ── Uptime monitoring (Uptime Kuma) ──────────────────────────────────────────
|
||||
# Only resolves if the monitoring stack is up (docker-compose.monitoring.yml).
|
||||
# Caddy ignores upstreams that don't exist until the container is running.
|
||||
status.{$DOMAIN} {
|
||||
reverse_proxy uptime-kuma:3001
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
|
||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Production}"
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
|
||||
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
||||
|
||||
@@ -168,7 +168,7 @@ services:
|
||||
dockerfile: docker/website/Dockerfile
|
||||
args:
|
||||
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
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -178,7 +178,7 @@ services:
|
||||
PORT: "3000"
|
||||
HOSTNAME: 0.0.0.0
|
||||
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:
|
||||
- "${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:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
|
||||
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Production}"
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
|
||||
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
|
||||
@@ -94,6 +94,10 @@ services:
|
||||
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
||||
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
||||
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__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
|
||||
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
|
||||
@@ -139,7 +143,7 @@ services:
|
||||
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
||||
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
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -149,7 +153,7 @@ services:
|
||||
PORT: "3000"
|
||||
HOSTNAME: 0.0.0.0
|
||||
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:
|
||||
- "${WEBSITE_PORT:-3010}:3000"
|
||||
|
||||
@@ -163,7 +167,7 @@ services:
|
||||
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
||||
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
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -173,10 +177,34 @@ services:
|
||||
PORT: "3000"
|
||||
HOSTNAME: 0.0.0.0
|
||||
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:
|
||||
- "${KOJA_PORT:-3103}:3000"
|
||||
|
||||
# Nightly Postgres backup — dumps the DB every night, keeps the last 14 days.
|
||||
# Dumps land in the host ./backups dir (bind mount) so they survive a full
|
||||
# container/volume wipe and can be rsync'd off-box. See scripts/backup/RESTORE.md.
|
||||
backup:
|
||||
image: ${POSTGRES_IMAGE:-mirror.soroushasadi.com/postgres:16-alpine}
|
||||
container_name: meezi-backup
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGHOST: postgres
|
||||
PGPORT: "5432"
|
||||
PGUSER: meezi
|
||||
PGPASSWORD: "${DB_PASSWORD:-meezi_local_pass}"
|
||||
PGDATABASE: meezi
|
||||
RETAIN_DAYS: "${BACKUP_RETAIN_DAYS:-14}"
|
||||
BACKUP_HOUR: "${BACKUP_HOUR:-2}"
|
||||
TZ: Asia/Tehran
|
||||
entrypoint: ["/bin/sh", "/backup/pg-backup-loop.sh"]
|
||||
volumes:
|
||||
- ./scripts/backup:/backup:ro
|
||||
- ${BACKUP_DIR:-./backups}:/backups
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
@@ -8,6 +8,11 @@ COPY global.json Directory.Build.props Directory.Packages.props ./
|
||||
# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
|
||||
COPY nuget.docker.config ./nuget.config
|
||||
|
||||
# Trust the Nexus mirror's TLS CA (new ISRG Root YR chain, not in the SDK image's
|
||||
# trust store). See docker/api/Dockerfile for the full rationale.
|
||||
COPY docker/nexus-mirror-ca.crt /usr/local/share/ca-certificates/nexus-mirror-ca.crt
|
||||
RUN update-ca-certificates
|
||||
|
||||
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
|
||||
COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/
|
||||
COPY src/Meezi.Infrastructure/Meezi.Infrastructure.csproj src/Meezi.Infrastructure/
|
||||
|
||||
@@ -8,6 +8,12 @@ COPY global.json Directory.Build.props Directory.Packages.props ./
|
||||
# nuget.docker.config points to Nexus mirror (mirror.soroushasadi.com)
|
||||
COPY nuget.docker.config ./nuget.config
|
||||
|
||||
# Trust the Nexus mirror's TLS CA: its Let's Encrypt cert renewed under the new
|
||||
# ISRG Root YR, which isn't in the SDK image's trust store yet. Add the mirror's
|
||||
# intermediate (CA:TRUE, valid to Sept 2028) as an anchor so dotnet restore validates.
|
||||
COPY docker/nexus-mirror-ca.crt /usr/local/share/ca-certificates/nexus-mirror-ca.crt
|
||||
RUN update-ca-certificates
|
||||
|
||||
COPY src/Meezi.Shared/Meezi.Shared.csproj src/Meezi.Shared/
|
||||
COPY src/Meezi.Core/Meezi.Core.csproj src/Meezi.Core/
|
||||
COPY src/Meezi.Infrastructure/Meezi.Infrastructure.csproj src/Meezi.Infrastructure/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIE2jCCAsKgAwIBAgIQTr0klH4k05SALYSlL9WzGTANBgkqhkiG9w0BAQsFADAu
|
||||
MQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzEQMA4GA1UEAxMHUm9vdCBZUjAe
|
||||
Fw0yNTA5MDMwMDAwMDBaFw0yODA5MDIyMzU5NTlaMDMxCzAJBgNVBAYTAlVTMRYw
|
||||
FAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQDEwNZUjIwggEiMA0GCSqGSIb3
|
||||
DQEBAQUAA4IBDwAwggEKAoIBAQDZ0LxwBppqh84luqMerV/eeL/fXQ7mLQQv1Lnp
|
||||
WKZbyvGpx6wh6AfnslAnF6ewTkcHA+gSOoBvm3Dfm06AuGiF+KRut4fAcowqnAQQ
|
||||
CW98+QPP/eOv/wug7Iyk4NkOxf2I6g2f55T6nJoOTLFcukeRq80JGQEYan+dPFr9
|
||||
OGUgQK2hGKgNkW87pappsOAuUJcroYhRt5uUis4qaZireiseu32gzDJNBAiKtsvd
|
||||
6HX4v25bpkRNcS/B/Gtc9kVbUpD+2PLPxdei3Tim55k4tfAEXwD2qyiPTxrTNq6l
|
||||
N+AMr5g2c1dNqkOTwjxeV6L5lpP1rGiYvLnRaPlOqyZRPW+5AgMBAAGjge4wgesw
|
||||
DgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMBMBIGA1UdEwEB/wQI
|
||||
MAYBAf8CAQAwHQYDVR0OBBYEFEAVLSZ57TIgnt+ach3WMh+BDIEMMB8GA1UdIwQY
|
||||
MBaAFN7nW2DQIm1AKH0/DQH+pLVStFGUMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEF
|
||||
BQcwAoYWaHR0cDovL3lyLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAEC
|
||||
ATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veXIuYy5sZW5jci5vcmcvMA0GCSqG
|
||||
SIb3DQEBCwUAA4ICAQB0ZUQWZ9/Yn9COEpo+JfecMnB0h0vwDm/M66IqXqw3LoaL
|
||||
mx9lZvRTeDIS67PUeI3yCA2W6PKRD0/FE/G57lOmS+Xy5AaaL00ICGOqjNcCaMWW
|
||||
8o8nevHOd4i4lqgtznE/28QwlcdJyF8yBiWHpnyjhEpmNWJURgOCOg2xpwRMBCsj
|
||||
MScqYPtOhBeuYQvSwAEeTML2Ukh6uGuX4E14q65Ja8cdjF5bAldnP1eE4FBaAwsZ
|
||||
G2fOqqrKV03Y85Nw2btedP1AtliQuJZs/Jo/gXxXdc7LrH3McgnpnbTiAncX7yES
|
||||
hP6kzQejllqMCIt52HOjxDGWafS7Xw+DKwqmH+Eqy8dcbOuag/1AYlQoKNVK3F5q
|
||||
Hh6tEDiMqQcLIibGKteE6iHo4A/bIScbzrhXUYuism42ZYzmc48FMVIH3qy4L84E
|
||||
TdAH2gtxw0PAhvRVXp8HP7wfngpzsN/8xOTpeRSbM4+Qbc56G6+Bifmv6sk1ieQb
|
||||
NA3wJdl4DDUuQSV8hBgx6zoI1ZSGORprDFux7c6rhc77QZMSRrEgomBeklervEve
|
||||
86ylWmZ3WWHV6RLMi8xNvjd71r4EPIGgY7BZU/VPBkq+uA7Gb6mbJnFgV43uh3xy
|
||||
LRFgxIAphIukwTGSMZZR+AI+Qnp0BYTWovHXozOf3H8r6hozEoT02JHn0AeTfA==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -23,7 +23,7 @@ FROM ${NODE_IMAGE} AS builder
|
||||
WORKDIR /app
|
||||
|
||||
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 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.
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 11 KiB |
@@ -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 Meezi.API.Models.Audit;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
@@ -42,7 +43,7 @@ public class AuditController : CafeApiControllerBase
|
||||
[FromQuery] int pageSize = 50)
|
||||
{
|
||||
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 (pageSize < 1) pageSize = 50;
|
||||
@@ -67,25 +68,50 @@ public class AuditController : CafeApiControllerBase
|
||||
|
||||
var total = await query.CountAsync(ct);
|
||||
|
||||
var items = await query
|
||||
var rows = await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new AuditLogDto(
|
||||
x.Id,
|
||||
x.Category,
|
||||
x.Action,
|
||||
x.EntityType,
|
||||
x.EntityId,
|
||||
x.BranchId,
|
||||
x.ActorId,
|
||||
x.ActorName,
|
||||
x.ActorRole,
|
||||
x.Summary,
|
||||
x.DetailsJson,
|
||||
x.CreatedAt))
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Category, x.Action, x.EntityType, x.EntityId, x.BranchId,
|
||||
x.ActorId, x.ActorName, x.ActorRole, x.Summary, x.DetailsJson, x.CreatedAt
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,23 @@ public class AuthController : ControllerBase
|
||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("login-key")]
|
||||
[EnableRateLimiting("auth-otp")]
|
||||
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> LoginWithRecoveryKey(
|
||||
[FromBody] LoginWithRecoveryKeyRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Key))
|
||||
return BadRequest(ValidationError("Recovery key is required."));
|
||||
|
||||
var (success, data, code, message) = await _authService.LoginWithRecoveryKeyAsync(request, cancellationToken);
|
||||
if (!success)
|
||||
return ErrorResult(code!, message!);
|
||||
|
||||
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
|
||||
}
|
||||
|
||||
[HttpPost("send-otp")]
|
||||
[EnableRateLimiting("auth-otp")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
|
||||
@@ -224,7 +241,9 @@ public class AuthController : ControllerBase
|
||||
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
|
||||
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
"INVALID_OTP" or "INVALID_TOKEN" or "INVALID_KEY" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
"CAFE_SUSPENDED" or "NO_OWNER" => StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
"BRANCH_FORBIDDEN" => StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
|
||||
|
||||
@@ -3,13 +3,14 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Billing;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
public class BillingController : ControllerBase
|
||||
public class BillingController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IBillingService _billing;
|
||||
private readonly IValidator<SubscribeRequest> _subscribeValidator;
|
||||
@@ -27,13 +28,9 @@ public class BillingController : ControllerBase
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
|
||||
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||
return Unauthorized();
|
||||
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
|
||||
{
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
|
||||
}
|
||||
|
||||
var validation = await _subscribeValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
@@ -108,11 +105,9 @@ public class BillingController : ControllerBase
|
||||
[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();
|
||||
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
|
||||
|
||||
var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
|
||||
if (!ok)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Menu;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
@@ -43,7 +43,6 @@ public class BranchMenuController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{menuItemId}/override")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> UpsertOverride(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
@@ -53,8 +52,7 @@ public class BranchMenuController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (!BranchMenuService.CanManageOverrides(tenant.Role))
|
||||
return Forbid();
|
||||
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||
|
||||
var validation = await _upsertValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
@@ -84,7 +82,6 @@ public class BranchMenuController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{menuItemId}/override")]
|
||||
[Authorize(Roles = "Owner")]
|
||||
public async Task<IActionResult> DeleteOverride(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
@@ -93,6 +90,7 @@ public class BranchMenuController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||
|
||||
var deleted = await _branchMenu.DeleteOverrideAsync(
|
||||
cafeId, branchId, menuItemId, cancellationToken);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
@@ -11,7 +11,6 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/branches/{branchId}/print-settings")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public class BranchPrintSettingsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
@@ -54,6 +53,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||
|
||||
var validation = await _validator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
@@ -91,6 +91,14 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
||||
: request.PosDeviceIp.Trim();
|
||||
if (request.PosDevicePort.HasValue)
|
||||
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;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
@@ -110,5 +118,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
||||
b.ReceiptFooter,
|
||||
b.WifiPassword,
|
||||
b.PosDeviceIp,
|
||||
b.PosDevicePort);
|
||||
b.PosDevicePort,
|
||||
b.ReceiptPrintDeviceId,
|
||||
b.KitchenPrintDeviceId);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Tables;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
@@ -68,7 +68,6 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> CreateTable(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
@@ -77,6 +76,7 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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))
|
||||
return Forbid();
|
||||
|
||||
@@ -88,7 +88,6 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> PatchTable(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
@@ -98,6 +97,7 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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))
|
||||
return Forbid();
|
||||
|
||||
@@ -109,7 +109,6 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> DeleteTable(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
@@ -118,6 +117,7 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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))
|
||||
return Forbid();
|
||||
|
||||
@@ -135,6 +135,7 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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))
|
||||
return Forbid();
|
||||
|
||||
@@ -180,7 +181,6 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("sections")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> CreateSection(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
@@ -189,6 +189,7 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
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))
|
||||
return Forbid();
|
||||
|
||||
@@ -200,7 +201,6 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
[HttpPatch("sections/{sectionId}")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> PatchSection(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
@@ -210,6 +210,7 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
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))
|
||||
return Forbid();
|
||||
|
||||
@@ -221,7 +222,6 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("sections/{sectionId}")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> DeleteSection(
|
||||
string cafeId,
|
||||
string branchId,
|
||||
@@ -230,6 +230,7 @@ public class BranchTablesController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
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))
|
||||
return Forbid();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Branches;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
@@ -96,7 +97,7 @@ public class BranchesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (!validation.IsValid)
|
||||
@@ -169,7 +170,7 @@ public class BranchesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (!validation.IsValid)
|
||||
@@ -222,7 +223,7 @@ public class BranchesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (!ok)
|
||||
@@ -257,7 +258,7 @@ public class BranchesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (!ok)
|
||||
|
||||
@@ -44,9 +44,14 @@ public abstract class CafeApiControllerBase : ControllerBase
|
||||
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)
|
||||
{
|
||||
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))
|
||||
return null;
|
||||
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 Meezi.API.Models.Discover;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
@@ -45,6 +46,7 @@ public class CafeDiscoverProfileController : CafeApiControllerBase
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied)
|
||||
return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
|
||||
|
||||
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
||||
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "discover_profile", ct))
|
||||
|
||||
@@ -36,4 +36,13 @@ public class CafePlatformController : CafeApiControllerBase
|
||||
var plans = await _catalog.GetPlansAsync(cancellationToken);
|
||||
return Ok(new ApiResponse<object>(true, plans));
|
||||
}
|
||||
|
||||
/// <summary>Feature catalog (key → display name / module group) so clients can
|
||||
/// label the FeatureKeys returned by the plans endpoint.</summary>
|
||||
[HttpGet("features-catalog")]
|
||||
public async Task<IActionResult> GetFeaturesCatalog(CancellationToken cancellationToken)
|
||||
{
|
||||
var features = await _catalog.GetFeaturesAsync(cancellationToken);
|
||||
return Ok(new ApiResponse<object>(true, features));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Discover;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
@@ -71,6 +72,7 @@ public class CafePublicProfileController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (cafe is null)
|
||||
@@ -121,6 +123,7 @@ public class CafePublicProfileController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
|
||||
|
||||
if (photo is null || photo.Length == 0)
|
||||
return BadRequest(Fail("NO_FILE", "No photo provided."));
|
||||
@@ -155,6 +158,7 @@ public class CafePublicProfileController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return BadRequest(Fail("NO_URL", "Provide ?url= of the photo to remove."));
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
@@ -48,6 +49,7 @@ public class CafeReviewsController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
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;
|
||||
@@ -76,6 +78,7 @@ public class CafeReviewsController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
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);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<CafeReviewDto>(true, data));
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Cafes;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
@@ -47,6 +48,7 @@ public class CafeSettingsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return permDenied;
|
||||
|
||||
var validation = await _validator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -62,6 +63,7 @@ public class CouponsController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.CreateCoupon) is { } permDenied) return permDenied;
|
||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -82,6 +84,7 @@ public class CouponsController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<CouponDto>(true, data));
|
||||
@@ -95,6 +98,7 @@ public class CouponsController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
if (!deleted) return NotFoundError();
|
||||
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 Meezi.API.Models.Crm;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -57,6 +58,7 @@ public class CustomersController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.CreateCustomer) is { } permDenied) return permDenied;
|
||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -77,6 +79,7 @@ public class CustomersController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.EditCustomer) is { } permDenied) return permDenied;
|
||||
var validation = await _updateValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -99,6 +102,7 @@ public class CustomersController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Services.Delivery;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -21,6 +22,7 @@ public class DeliveryReportsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||
|
||||
var utcTo = to ?? DateTime.UtcNow;
|
||||
var utcFrom = from ?? utcTo.AddDays(-30);
|
||||
|
||||
@@ -2,7 +2,7 @@ using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Expenses;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -30,14 +30,11 @@ public class ExpensesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.CreateExpense) is { } permDenied) return permDenied;
|
||||
if (string.IsNullOrEmpty(tenant.UserId))
|
||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||
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);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -57,6 +54,7 @@ public class ExpensesController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ViewExpenses) is { } permDenied) return permDenied;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(branchId))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
@@ -85,10 +83,7 @@ public class ExpensesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
if (!CanDeleteExpense(tenant.Role))
|
||||
return StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Only managers can delete expenses.")));
|
||||
if (EnsurePermission(tenant, Permission.DeleteExpense) is { } permDenied) return permDenied;
|
||||
|
||||
var result = await _expenses.DeleteExpenseAsync(cafeId, id, ct);
|
||||
if (!result.Success)
|
||||
@@ -104,12 +99,6 @@ public class ExpensesController : CafeApiControllerBase
|
||||
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)
|
||||
{
|
||||
if (result.Success)
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Hr;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
@@ -43,6 +44,7 @@ public class HrController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
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);
|
||||
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
|
||||
}
|
||||
@@ -57,7 +59,7 @@ public class HrController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
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)));
|
||||
@@ -183,6 +185,7 @@ public class HrController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
return Ok(new ApiResponse<IReadOnlyList<AttendanceDto>>(true, data));
|
||||
}
|
||||
@@ -191,6 +194,7 @@ public class HrController : CafeApiControllerBase
|
||||
public async Task<IActionResult> GetShifts(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
||||
}
|
||||
@@ -204,7 +208,7 @@ public class HrController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
||||
}
|
||||
@@ -217,6 +221,7 @@ public class HrController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
return Ok(new ApiResponse<IReadOnlyList<LeaveRequestDto>>(true, data));
|
||||
}
|
||||
@@ -248,7 +253,7 @@ public class HrController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -265,6 +270,7 @@ public class HrController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
return Ok(new ApiResponse<IReadOnlyList<EmployeeSalaryDto>>(true, data));
|
||||
}
|
||||
@@ -277,7 +283,7 @@ public class HrController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -290,7 +296,7 @@ public class HrController : CafeApiControllerBase
|
||||
public async Task<IActionResult> MarkPaid(string cafeId, string salaryId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
||||
@@ -306,7 +312,7 @@ public class HrController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -344,7 +350,7 @@ public class HrController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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
|
||||
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -36,6 +37,7 @@ public class InventoryController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.CreateInventory) is { } permDenied) return permDenied;
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required.")));
|
||||
|
||||
@@ -56,6 +58,7 @@ public class InventoryController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (updated is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, updated));
|
||||
@@ -69,6 +72,7 @@ public class InventoryController : CafeApiControllerBase
|
||||
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 }));
|
||||
@@ -83,6 +87,7 @@ public class InventoryController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.EditInventory) is { } permDenied) return permDenied;
|
||||
try
|
||||
{
|
||||
var updated = await _inventory.AdjustAsync(cafeId, ingredientId, request, tenant.UserId, ct);
|
||||
@@ -146,6 +151,7 @@ public class InventoryController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (recipe is null) return NotFoundError("Menu item not found.");
|
||||
return Ok(new ApiResponse<object>(true, recipe));
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Kitchen;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -40,6 +41,7 @@ public class KitchenStationsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
|
||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -59,6 +61,7 @@ public class KitchenStationsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
|
||||
var validation = await _updateValidator.ValidateAsync(request, ct);
|
||||
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)
|
||||
{
|
||||
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);
|
||||
if (!ok) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
@@ -20,13 +21,21 @@ public class MediaController : CafeApiControllerBase
|
||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadMenuImage(
|
||||
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")]
|
||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadMenuVideo(
|
||||
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")]
|
||||
[RequestSizeLimit(8 * 1024 * 1024)]
|
||||
@@ -38,6 +47,7 @@ public class MediaController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
||||
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "menu_3d", cancellationToken))
|
||||
{
|
||||
@@ -63,25 +73,41 @@ public class MediaController : CafeApiControllerBase
|
||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadTableImage(
|
||||
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")]
|
||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadTableVideo(
|
||||
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")]
|
||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadCafeLogo(
|
||||
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")]
|
||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||
public Task<IActionResult> UploadCafeCover(
|
||||
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>
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Menu;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
@@ -59,6 +60,7 @@ public class MenuController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.CreateMenuItem) is { } permDenied) return permDenied;
|
||||
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -86,6 +88,7 @@ public class MenuController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
||||
@@ -95,6 +98,7 @@ public class MenuController : CafeApiControllerBase
|
||||
public async Task<IActionResult> DeleteCategory(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.DeleteCategoryAsync(cafeId, id, cancellationToken);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
@@ -120,6 +124,7 @@ public class MenuController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.CreateMenuItem) is { } permDenied) return permDenied;
|
||||
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -148,6 +153,7 @@ public class MenuController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||
@@ -162,6 +168,7 @@ public class MenuController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||
@@ -171,6 +178,7 @@ public class MenuController : CafeApiControllerBase
|
||||
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 }));
|
||||
@@ -193,6 +201,7 @@ public class MenuController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var (data, code, message) = await _menuAi3d.GenerateFromItemImageAsync(cafeId, id, tier, cancellationToken);
|
||||
if (code is not null)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Services;
|
||||
@@ -20,6 +19,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
private readonly IValidator<RecordPaymentsRequest> _paymentsValidator;
|
||||
private readonly IValidator<AppendOrderItemsRequest> _appendValidator;
|
||||
private readonly IValidator<UpdateOrderSessionRequest> _sessionValidator;
|
||||
private readonly IValidator<CorrectPaymentsRequest> _correctionValidator;
|
||||
|
||||
public OrdersController(
|
||||
IOrderService orderService,
|
||||
@@ -28,7 +28,8 @@ public class OrdersController : CafeApiControllerBase
|
||||
IValidator<UpdateOrderStatusRequest> statusValidator,
|
||||
IValidator<RecordPaymentsRequest> paymentsValidator,
|
||||
IValidator<AppendOrderItemsRequest> appendValidator,
|
||||
IValidator<UpdateOrderSessionRequest> sessionValidator)
|
||||
IValidator<UpdateOrderSessionRequest> sessionValidator,
|
||||
IValidator<CorrectPaymentsRequest> correctionValidator)
|
||||
{
|
||||
_orderService = orderService;
|
||||
_audit = audit;
|
||||
@@ -37,6 +38,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
_paymentsValidator = paymentsValidator;
|
||||
_appendValidator = appendValidator;
|
||||
_sessionValidator = sessionValidator;
|
||||
_correctionValidator = correctionValidator;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -63,6 +65,35 @@ public class OrdersController : CafeApiControllerBase
|
||||
return Ok(new ApiResponse<IReadOnlyList<OrderDto>>(true, data));
|
||||
}
|
||||
|
||||
/// <summary>Closed orders (delivered/cancelled) of one Iran-calendar day — the
|
||||
/// browsing surface for اصلاح سند payment corrections.</summary>
|
||||
[HttpGet("closed")]
|
||||
public async Task<IActionResult> GetClosedOrders(
|
||||
string cafeId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery] string? date = null,
|
||||
[FromQuery] string? branchId = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 30)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureBranchAccess(branchId, tenant) is { } branchDenied) return branchDenied;
|
||||
|
||||
DateOnly day;
|
||||
if (string.IsNullOrWhiteSpace(date)) day = IranCalendar.TodayInIran;
|
||||
else if (!DateOnly.TryParse(date, out day))
|
||||
return BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError("VALIDATION_ERROR", "Invalid date (expected YYYY-MM-DD).", "date")));
|
||||
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize is < 1 or > 100) pageSize = 30;
|
||||
|
||||
var (items, total) = await _orderService.GetClosedOrdersAsync(
|
||||
cafeId, day, branchId, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedApiResponse<OrderDto>(true, items, new PagedMeta(total, page, pageSize)));
|
||||
}
|
||||
|
||||
[HttpGet("live")]
|
||||
public async Task<IActionResult> GetLiveOrders(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -88,6 +119,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ProcessOrders) is { } permDenied) return permDenied;
|
||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -107,6 +139,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
|
||||
var validation = await _appendValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -118,7 +151,6 @@ public class OrdersController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/items/{itemId}/void")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> VoidOrderItem(
|
||||
string cafeId,
|
||||
string id,
|
||||
@@ -127,6 +159,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.VoidOrder) is { } permDenied) return permDenied;
|
||||
if (string.IsNullOrEmpty(tenant.UserId))
|
||||
return StatusCode(StatusCodes.Status403Forbidden,
|
||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User context required.")));
|
||||
@@ -149,7 +182,6 @@ public class OrdersController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id}/transfer")]
|
||||
[Authorize(Roles = "Manager,Owner,Waiter")]
|
||||
public async Task<IActionResult> TransferTable(
|
||||
string cafeId,
|
||||
string id,
|
||||
@@ -158,6 +190,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
if (!result.Success)
|
||||
@@ -175,6 +208,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
|
||||
var validation = await _sessionValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -194,6 +228,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.UpdateOrderStatus) is { } permDenied) return permDenied;
|
||||
var validation = await _statusValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -211,7 +246,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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(
|
||||
cafeId, id, request.Reason, tenant.UserId, cancellationToken);
|
||||
@@ -247,6 +282,7 @@ public class OrdersController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.HandlePayments) is { } permDenied) return permDenied;
|
||||
var validation = await _paymentsValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -273,6 +309,56 @@ public class OrdersController : CafeApiControllerBase
|
||||
return Ok(new ApiResponse<IReadOnlyList<PaymentDto>>(true, result.Data));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// اصلاح سند — void wrongly-recorded payments and/or record replacements on a
|
||||
/// closed order, atomically, with a mandatory reason. Manager/Owner only;
|
||||
/// the full before/after is written to the immutable audit trail.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/payments/corrections")]
|
||||
public async Task<IActionResult> CorrectPayments(
|
||||
string cafeId,
|
||||
string id,
|
||||
[FromBody] CorrectPaymentsRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (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) =>
|
||||
code switch
|
||||
{
|
||||
@@ -290,6 +376,8 @@ public class OrdersController : CafeApiControllerBase
|
||||
false, null, new ApiError(code, "Order is already cancelled.", field))),
|
||||
"ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Refund the recorded payments before cancelling this order.", field))),
|
||||
"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>(
|
||||
false, null, new ApiError(code, "Line item not found.", field))),
|
||||
"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))),
|
||||
"NO_OPEN_SHIFT" => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Open the cash register shift before taking payment.", field))),
|
||||
"PAYMENT_NOT_FOUND" => NotFound(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Payment not found on this order.", field))),
|
||||
"PAYMENT_ALREADY_REFUNDED" => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Payment is already refunded.", field))),
|
||||
_ => BadRequest(new ApiResponse<object>(
|
||||
false, null, new ApiError(code, "Invalid order request.", field)))
|
||||
};
|
||||
|
||||
@@ -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 Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
[Route("api/cafes/{cafeId}/branches/{branchId}/pos-device")]
|
||||
[Authorize(Roles = "Cashier,Manager,Owner")]
|
||||
public class PosDeviceController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IPosDeviceService _posDevice;
|
||||
@@ -30,6 +29,7 @@ public class PosDeviceController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.HandlePayments) is { } permDenied) return permDenied;
|
||||
|
||||
var validation = await _validator.ValidateAsync(request, ct);
|
||||
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 Meezi.API.Models.Printing;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -32,16 +32,18 @@ public class PrintController : CafeApiControllerBase
|
||||
string cafeId,
|
||||
string orderId,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
CancellationToken ct,
|
||||
[FromQuery] string? stationId)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[HttpPost("test")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> TestPrint(
|
||||
string cafeId,
|
||||
[FromBody] TestPrintRequest request,
|
||||
@@ -49,6 +51,7 @@ public class PrintController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
return ToActionResult(result);
|
||||
@@ -62,7 +65,8 @@ public class PrintController : CafeApiControllerBase
|
||||
|
||||
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,
|
||||
_ => StatusCodes.Status502BadGateway
|
||||
};
|
||||
@@ -75,6 +79,7 @@ public class PrintController : CafeApiControllerBase
|
||||
{
|
||||
"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.",
|
||||
"NO_STATION_ITEMS" => "This order has no items for the selected station.",
|
||||
"PRINTER_CONNECTION_FAILED" => "Could not connect to the printer.",
|
||||
"ORDER_NOT_FOUND" => "Order not found.",
|
||||
_ => "Print failed."
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -13,19 +16,19 @@ namespace Meezi.API.Controllers;
|
||||
///
|
||||
/// POST /api/public/push/register — anonymous device registration
|
||||
/// POST /api/public/push/unregister — anonymous device removal
|
||||
/// POST /api/push/broadcast — authorized topic broadcast (marketing /
|
||||
/// saved-café alerts)
|
||||
/// POST /api/push/broadcast — café marketing push (own topic only)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PushController : ControllerBase
|
||||
public class PushController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IPushDeviceService _devices;
|
||||
private readonly IPushSender _sender;
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public PushController(IPushDeviceService devices, IPushSender sender)
|
||||
public PushController(IPushDeviceService devices, IPushSender sender, AppDbContext db)
|
||||
{
|
||||
_devices = devices;
|
||||
_sender = sender;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpPost("api/public/push/register")]
|
||||
@@ -53,15 +56,26 @@ public class PushController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("api/push/broadcast")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Broadcast(
|
||||
[FromBody] BroadcastPushRequest request, CancellationToken ct)
|
||||
[FromBody] BroadcastPushRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Topic))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("INVALID_TOPIC", "Topic is required.")));
|
||||
if (EnsurePermission(tenant, Permission.SendSms) is { } forbidden) return forbidden;
|
||||
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Queue;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
@@ -37,6 +38,7 @@ public class QueueController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
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);
|
||||
if (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)
|
||||
{
|
||||
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);
|
||||
if (error == "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)
|
||||
{
|
||||
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 next = board.Tickets.FirstOrDefault(t => t.Status == QueueTicketStatus.Waiting);
|
||||
if (next is null)
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Reports;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.API.Utils;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
@@ -38,6 +39,7 @@ public class ReportsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||
if (string.IsNullOrWhiteSpace(branchId))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
|
||||
@@ -65,6 +67,7 @@ public class ReportsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
@@ -99,6 +102,7 @@ public class ReportsController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||
|
||||
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||
if (days > maxDays && maxDays != int.MaxValue)
|
||||
@@ -120,6 +124,7 @@ public class ReportsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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 _))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali date. Use yyyy-MM-dd.")));
|
||||
@@ -136,6 +141,7 @@ public class ReportsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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 _))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
|
||||
@@ -152,6 +158,7 @@ public class ReportsController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
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);
|
||||
return Ok(new ApiResponse<IReadOnlyList<TrendDayDto>>(true, data));
|
||||
}
|
||||
@@ -165,6 +172,7 @@ public class ReportsController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ExportReports) is { } permDenied) return permDenied;
|
||||
if (!string.Equals(format, "excel", StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("VALIDATION_ERROR", "Only excel format is supported.")));
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
@@ -30,6 +31,7 @@ public class ReservationsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.CreateReservation) is { } permDenied) return permDenied;
|
||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -62,6 +64,7 @@ public class ReservationsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<ReservationDto>(true, data));
|
||||
@@ -75,6 +78,7 @@ public class ReservationsController : CafeApiControllerBase
|
||||
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 }));
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Shifts;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -33,6 +34,7 @@ public class ShiftsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.OperateRegister) is { } permDenied) return permDenied;
|
||||
if (string.IsNullOrEmpty(tenant.UserId))
|
||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
||||
@@ -54,6 +56,7 @@ public class ShiftsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.OperateRegister) is { } permDenied) return permDenied;
|
||||
if (string.IsNullOrEmpty(tenant.UserId))
|
||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
||||
|
||||
@@ -2,38 +2,70 @@ using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Marketing SMS — bring-your-own-provider. Each café configures its OWN
|
||||
/// Kavenegar API key + sender line; the platform does not sell SMS.
|
||||
/// </summary>
|
||||
[Route("api/cafes/{cafeId}/sms")]
|
||||
public class SmsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ISmsMarketingService _smsMarketingService;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
|
||||
|
||||
public SmsController(
|
||||
ISmsMarketingService smsMarketingService,
|
||||
ISmsService smsService,
|
||||
IValidator<SendSmsCampaignRequest> campaignValidator)
|
||||
{
|
||||
_smsMarketingService = smsMarketingService;
|
||||
_smsService = smsService;
|
||||
_campaignValidator = campaignValidator;
|
||||
}
|
||||
|
||||
[HttpGet("settings")]
|
||||
public async Task<IActionResult> GetSettings(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
||||
|
||||
var data = await _smsMarketingService.GetSettingsAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<SmsSettingsDto>(true, data));
|
||||
}
|
||||
|
||||
[HttpPut("settings")]
|
||||
public async Task<IActionResult> UpdateSettings(
|
||||
string cafeId,
|
||||
[FromBody] UpdateSmsSettingsRequest request,
|
||||
ITenantContext tenant,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (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")]
|
||||
public async Task<IActionResult> GetBalance(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
var info = await _smsService.GetAccountInfoAsync(cancellationToken);
|
||||
var dto = info is not null
|
||||
? new SmsBalanceDto(info.RemainCredit, info.AccountType, true)
|
||||
: new SmsBalanceDto(0, "master", false);
|
||||
|
||||
var dto = await _smsMarketingService.GetBalanceAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<SmsBalanceDto>(true, dto));
|
||||
}
|
||||
|
||||
@@ -41,10 +73,8 @@ public class SmsController : CafeApiControllerBase
|
||||
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (tenant.PlanTier is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
|
||||
|
||||
var data = await _smsMarketingService.GetUsageAsync(cafeId, tenant.PlanTier.Value, cancellationToken);
|
||||
var data = await _smsMarketingService.GetUsageAsync(cafeId, cancellationToken);
|
||||
return Ok(new ApiResponse<SmsUsageDto>(true, data));
|
||||
}
|
||||
|
||||
@@ -56,20 +86,19 @@ public class SmsController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (tenant.PlanTier is null)
|
||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID", "Plan tier missing.")));
|
||||
if (EnsurePermission(tenant, Permission.SendSms) is { } permDenied) return permDenied;
|
||||
|
||||
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var (success, data, code, message) = await _smsMarketingService.SendCampaignAsync(
|
||||
cafeId, tenant.PlanTier.Value, request, cancellationToken);
|
||||
cafeId, request, cancellationToken);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"PLAN_LIMIT_REACHED" => StatusCode(StatusCodes.Status403Forbidden,
|
||||
"SMS_NOT_CONFIGURED" => BadRequest(
|
||||
new ApiResponse<object>(false, null, new ApiError(code, message!))),
|
||||
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message!))),
|
||||
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code!, message!)))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Orders;
|
||||
using Meezi.API.Models.Tables;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -65,6 +65,7 @@ public class TablesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -82,6 +83,7 @@ public class TablesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||
var validation = await _patchValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
@@ -104,7 +106,6 @@ public class TablesController : CafeApiControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Manager,Owner")]
|
||||
public async Task<IActionResult> DeleteTable(
|
||||
string cafeId,
|
||||
string id,
|
||||
@@ -112,6 +113,7 @@ public class TablesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||
|
||||
var result = await _tableService.DeleteTableAsync(cafeId, id, ct);
|
||||
if (!result.Success)
|
||||
@@ -135,6 +137,7 @@ public class TablesController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||
var validation = await _cleaningValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -19,6 +20,7 @@ public class TarazController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageFinancials) is { } permDenied) return permDenied;
|
||||
|
||||
var targetDate = date ?? DateTime.UtcNow.Date;
|
||||
var result = await _taraz.SubmitDailyInvoicesAsync(cafeId, targetDate, ct);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Taxes;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
@@ -29,7 +30,7 @@ public class TaxesController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
if (EnsurePermission(tenant, Permission.CreateTax) is { } permDenied) return permDenied;
|
||||
var data = await _taxService.CreateAsync(cafeId, request, cancellationToken);
|
||||
return Ok(new ApiResponse<TaxDto>(true, data));
|
||||
}
|
||||
@@ -43,7 +44,7 @@ public class TaxesController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
if (EnsurePermission(tenant, Permission.EditTax) is { } permDenied) return permDenied;
|
||||
var data = await _taxService.UpdateAsync(cafeId, id, request, cancellationToken);
|
||||
if (data is null) return NotFoundError();
|
||||
return Ok(new ApiResponse<TaxDto>(true, data));
|
||||
@@ -57,7 +58,7 @@ public class TaxesController : CafeApiControllerBase
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
||||
if (EnsurePermission(tenant, Permission.DeleteTax) is { } permDenied) return permDenied;
|
||||
var deleted = await _taxService.DeleteAsync(cafeId, id, cancellationToken);
|
||||
if (!deleted) return NotFoundError();
|
||||
return Ok(new ApiResponse<object>(true, new { id }));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.API.Services;
|
||||
@@ -52,6 +53,7 @@ public class TerminalsController : CafeApiControllerBase
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return permDenied;
|
||||
await _terminals.RevokeAsync(cafeId, terminalId, ct);
|
||||
return Ok(new ApiResponse<object>(true, new { revoked = true }));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ using Meezi.API.Services;
|
||||
using Meezi.API.Services.Delivery;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.API.Services.Payments;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure;
|
||||
using Serilog;
|
||||
@@ -94,6 +95,14 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IDemoSeedService, DemoSeedService>();
|
||||
services.AddScoped<ReceiptBuilder>();
|
||||
services.AddScoped<IPrinterService, NetworkPrinterService>();
|
||||
services.AddSingleton<IPrintAgentRegistry, PrintAgentRegistry>();
|
||||
services.Configure<FlatPayOptions>(configuration.GetSection(FlatPayOptions.SectionName));
|
||||
services.AddHttpClient<IFlatPayService, FlatPayService>((sp, c) =>
|
||||
{
|
||||
var baseUrl = configuration["FlatPay:BaseUrl"];
|
||||
c.BaseAddress = new Uri(string.IsNullOrWhiteSpace(baseUrl) ? "https://pay.flatrender.ir" : baseUrl);
|
||||
c.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
services.AddHttpClient(nameof(PosDeviceService));
|
||||
services.AddScoped<IPosDeviceService, PosDeviceService>();
|
||||
services.AddScoped<SubscriptionRenewalReminderJob>();
|
||||
@@ -224,6 +233,7 @@ public static class ServiceCollectionExtensions
|
||||
app.MapControllers();
|
||||
app.MapHub<KdsHub>("/hubs/kds");
|
||||
app.MapHub<GuestOrderHub>("/hubs/guest-order");
|
||||
app.MapHub<PrintAgentHub>("/hubs/print-agent");
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
|
||||
|
||||
if (!app.Configuration.GetValue<bool>("Testing:Enabled"))
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
|
||||
namespace Meezi.API.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Local print agents connect here (outbound from the café PC), authenticated by
|
||||
/// their token in the <c>access_token</c> query param — agents are not users, so
|
||||
/// the hub self-authenticates rather than relying on the user JWT pipeline.
|
||||
/// They report the printers they can see and receive print jobs to relay locally.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
public class PrintAgentHub : Hub
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPrintAgentRegistry _registry;
|
||||
private readonly ILogger<PrintAgentHub> _logger;
|
||||
|
||||
public PrintAgentHub(AppDbContext db, IPrintAgentRegistry registry, ILogger<PrintAgentHub> logger)
|
||||
{
|
||||
_db = db;
|
||||
_registry = registry;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>SHA-256 (hex) of an agent token — what we persist and compare against.</summary>
|
||||
public static string HashToken(string token) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
var token = Context.GetHttpContext()?.Request.Query["access_token"].ToString();
|
||||
if (string.IsNullOrEmpty(token)) { Context.Abort(); return; }
|
||||
|
||||
var hash = HashToken(token);
|
||||
var agent = await _db.PrintAgents
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(a => a.TokenHash == hash && !a.Revoked && a.DeletedAt == null);
|
||||
if (agent is null) { Context.Abort(); return; }
|
||||
|
||||
_registry.Register(Context.ConnectionId, agent.Id, agent.CafeId);
|
||||
agent.LastSeenAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
_registry.Unregister(Context.ConnectionId);
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
public record ReportedPrinter(string SystemName, string DisplayName, string? Kind);
|
||||
|
||||
/// <summary>Agent → cloud: the current set of printers it can reach. Upserts devices.</summary>
|
||||
public async Task ReportPrinters(IReadOnlyList<ReportedPrinter> printers)
|
||||
{
|
||||
if (_registry.Resolve(Context.ConnectionId) is not { } ctx) return;
|
||||
|
||||
var existing = await _db.PrintDevices.IgnoreQueryFilters()
|
||||
.Where(d => d.AgentId == ctx.AgentId)
|
||||
.ToListAsync();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var p in printers ?? [])
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(p.SystemName)) continue;
|
||||
var match = existing.FirstOrDefault(d => d.SystemName == p.SystemName);
|
||||
if (match is null)
|
||||
{
|
||||
_db.PrintDevices.Add(new PrintDevice
|
||||
{
|
||||
CafeId = ctx.CafeId,
|
||||
AgentId = ctx.AgentId,
|
||||
SystemName = p.SystemName,
|
||||
DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? p.SystemName : p.DisplayName,
|
||||
Kind = string.IsNullOrWhiteSpace(p.Kind) ? "other" : p.Kind!,
|
||||
LastSeenAt = now,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
match.DisplayName = string.IsNullOrWhiteSpace(p.DisplayName) ? match.DisplayName : p.DisplayName;
|
||||
if (!string.IsNullOrWhiteSpace(p.Kind)) match.Kind = p.Kind!;
|
||||
match.LastSeenAt = now;
|
||||
match.DeletedAt = null; // a printer that came back is no longer "gone"
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Agent → cloud: acknowledgement of a dispatched print job.</summary>
|
||||
public void JobResult(string jobId, bool success, string? error) =>
|
||||
_registry.CompleteJob(jobId, success, error);
|
||||
|
||||
/// <summary>Agent → cloud: result of a relayed card-terminal payment.</summary>
|
||||
public void PaymentResult(string requestId, bool success, string? error) =>
|
||||
_registry.CompleteJob(requestId, success, error);
|
||||
|
||||
/// <summary>Agent → cloud: hosts found by a LAN scan (network printers, card terminals).</summary>
|
||||
public void ReportScan(string requestId, IReadOnlyList<DiscoveredDevice> devices) =>
|
||||
_registry.CompleteScan(requestId, devices ?? []);
|
||||
|
||||
/// <summary>Agent → cloud: keep-alive so the dashboard can show an accurate "last seen".</summary>
|
||||
public async Task Heartbeat()
|
||||
{
|
||||
if (_registry.Resolve(Context.ConnectionId) is not { } ctx) return;
|
||||
var agent = await _db.PrintAgents.IgnoreQueryFilters().FirstOrDefaultAsync(a => a.Id == ctx.AgentId);
|
||||
if (agent is null) return;
|
||||
agent.LastSeenAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
@@ -116,6 +117,16 @@ public class TenantMiddleware
|
||||
else
|
||||
_logger.LogWarning("Ignoring invalid or inactive branchId claim for cafe {CafeId}", cafeId);
|
||||
}
|
||||
|
||||
var customPermsClaim = context.User.FindFirst(MeeziClaimTypes.CustomPermissions)?.Value;
|
||||
if (!string.IsNullOrEmpty(customPermsClaim))
|
||||
{
|
||||
var set = new HashSet<Permission>();
|
||||
foreach (var name in customPermsClaim.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
if (Enum.TryParse<Permission>(name, ignoreCase: true, out var p))
|
||||
set.Add(p);
|
||||
scopedMerchant.CustomPermissions = set;
|
||||
}
|
||||
}
|
||||
|
||||
if (branchContext is BranchContext scopedBranch)
|
||||
|
||||
@@ -5,6 +5,9 @@ public record SendOtpRequest(string Phone);
|
||||
/// <summary>Username + password login (alternative to OTP). Optional cafeId to scope to a specific café.</summary>
|
||||
public record LoginWithPasswordRequest(string Username, string Password, string? CafeId = null);
|
||||
|
||||
/// <summary>Admin-issued recovery key login — logs the café Owner in when OTP access is lost.</summary>
|
||||
public record LoginWithRecoveryKeyRequest(string Key);
|
||||
|
||||
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
|
||||
|
||||
public record RefreshTokenRequest(string RefreshToken);
|
||||
|
||||
@@ -15,8 +15,6 @@ public record BillingStatusDto(
|
||||
int? OrdersDailyLimit,
|
||||
int CustomersCount,
|
||||
int? CustomersLimit,
|
||||
int SmsUsedThisMonth,
|
||||
int SmsMonthlyLimit,
|
||||
bool Menu3dEnabled,
|
||||
bool MenuAi3dEnabled,
|
||||
int MenuAi3dUsedThisMonth,
|
||||
|
||||
@@ -13,3 +13,11 @@ public record SmsUsageDto(int UsedThisMonth, int MonthlyLimit, string Month);
|
||||
|
||||
/// <summary>Kavenegar account credit balance returned to the dashboard.</summary>
|
||||
public record SmsBalanceDto(long RemainCredit, string AccountType, bool IsConfigured);
|
||||
|
||||
/// <summary>
|
||||
/// Café's own SMS provider settings (bring-your-own-provider). The API key is
|
||||
/// returned masked — only the last 4 characters are ever echoed back.
|
||||
/// </summary>
|
||||
public record SmsSettingsDto(bool IsConfigured, string? ApiKeyMasked, string? SenderNumber);
|
||||
|
||||
public record UpdateSmsSettingsRequest(string? ApiKey, string? SenderNumber);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Meezi.API.Models.CustomRoles;
|
||||
|
||||
public record CustomRoleDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? Color,
|
||||
IReadOnlyList<string> Permissions,
|
||||
int EmployeeCount,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record CreateCustomRoleRequest(
|
||||
string Name,
|
||||
string? Description = null,
|
||||
string? Color = null,
|
||||
IReadOnlyList<string>? Permissions = null);
|
||||
|
||||
public record UpdateCustomRoleRequest(
|
||||
string? Name = null,
|
||||
string? Description = null,
|
||||
string? Color = null,
|
||||
IReadOnlyList<string>? Permissions = null);
|
||||
|
||||
public record AssignCustomRoleRequest(string? CustomRoleId);
|
||||
@@ -7,18 +7,21 @@ public record KitchenStationDto(
|
||||
string? PrinterIp,
|
||||
int PrinterPort,
|
||||
int SortOrder,
|
||||
int CategoryCount);
|
||||
int CategoryCount,
|
||||
string? PrintDeviceId);
|
||||
|
||||
public record CreateKitchenStationRequest(
|
||||
string Name,
|
||||
string? BranchId,
|
||||
string? PrinterIp,
|
||||
int PrinterPort = 9100,
|
||||
int SortOrder = 0);
|
||||
int SortOrder = 0,
|
||||
string? PrintDeviceId = null);
|
||||
|
||||
public record UpdateKitchenStationRequest(
|
||||
string? Name,
|
||||
string? BranchId,
|
||||
string? PrinterIp,
|
||||
int? PrinterPort,
|
||||
int? SortOrder);
|
||||
int? SortOrder,
|
||||
string? PrintDeviceId);
|
||||
|
||||
@@ -55,7 +55,8 @@ public record MenuItemDto(
|
||||
string? ImageUrl,
|
||||
string? VideoUrl,
|
||||
string? Model3dUrl,
|
||||
bool IsAvailable);
|
||||
bool IsAvailable,
|
||||
string? KitchenStationId);
|
||||
|
||||
public record CreateMenuItemRequest(
|
||||
string CategoryId,
|
||||
@@ -68,7 +69,8 @@ public record CreateMenuItemRequest(
|
||||
string? ImageUrl = null,
|
||||
string? VideoUrl = null,
|
||||
string? Model3dUrl = null,
|
||||
bool IsAvailable = true);
|
||||
bool IsAvailable = true,
|
||||
string? KitchenStationId = null);
|
||||
|
||||
public record UpdateMenuItemRequest(
|
||||
string? CategoryId,
|
||||
@@ -81,6 +83,7 @@ public record UpdateMenuItemRequest(
|
||||
string? ImageUrl,
|
||||
string? VideoUrl,
|
||||
string? Model3dUrl,
|
||||
bool? IsAvailable);
|
||||
bool? IsAvailable,
|
||||
string? KitchenStationId);
|
||||
|
||||
public record UpdateMenuItemAvailabilityRequest(bool IsAvailable);
|
||||
|
||||
@@ -10,7 +10,11 @@ public record OrderItemDto(
|
||||
decimal UnitPrice,
|
||||
string? Notes,
|
||||
bool IsVoided = false,
|
||||
DateTime? VoidedAt = null);
|
||||
DateTime? VoidedAt = null,
|
||||
// Prep station the item routes to (Kitchen/Bar). Populated on the live/KDS
|
||||
// path only; null elsewhere (= the branch kitchen / no station).
|
||||
string? StationId = null,
|
||||
string? StationName = null);
|
||||
|
||||
public record TransferTableRequest(string TargetTableId);
|
||||
|
||||
@@ -70,6 +74,17 @@ public record RecordPaymentsRequest(
|
||||
IReadOnlyList<CreatePaymentRequest> Payments,
|
||||
int? LoyaltyPointsToRedeem = null);
|
||||
|
||||
/// <summary>
|
||||
/// اصلاح سند — amend the payments of an order after the fact (wrong method,
|
||||
/// wrong amount, or payment recorded on the wrong order). Voids the listed
|
||||
/// payments (marked Refunded, never deleted) and records the replacements in
|
||||
/// one atomic operation. A reason is mandatory; the whole change is audit-logged.
|
||||
/// </summary>
|
||||
public record CorrectPaymentsRequest(
|
||||
IReadOnlyList<string> VoidPaymentIds,
|
||||
IReadOnlyList<CreatePaymentRequest> Replacements,
|
||||
string Reason);
|
||||
|
||||
public record PaymentDto(string Id, PaymentMethod Method, decimal Amount, PaymentStatus Status, string? Reference);
|
||||
|
||||
public record LiveOrderDto(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Meezi.API.Models.Payments;
|
||||
|
||||
/// <summary>Body for POST /api/payment/request. ProductId is a "Tier:Months" bundle, e.g. "Pro:12".</summary>
|
||||
public record PaymentRequestDto(string ProductId);
|
||||
|
||||
public record PaymentRequestResponse(string Url, string PaymentId);
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Meezi.API.Models.Printing;
|
||||
|
||||
public record PrintAgentDeviceDto(
|
||||
string Id,
|
||||
string SystemName,
|
||||
string DisplayName,
|
||||
string Kind,
|
||||
DateTime LastSeenAt);
|
||||
|
||||
public record PrintAgentDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string? BranchId,
|
||||
bool Online,
|
||||
bool Paired,
|
||||
DateTime? LastSeenAt,
|
||||
DateTime CreatedAt,
|
||||
IReadOnlyList<PrintAgentDeviceDto> Devices);
|
||||
|
||||
public record CreatePairingCodeRequest(string? Name, string? BranchId);
|
||||
|
||||
public record PairingCodeResponse(string AgentId, string Code, DateTime ExpiresAt);
|
||||
|
||||
public record ClaimAgentRequest(string Code, string? Name, string? MachineName);
|
||||
|
||||
public record ClaimAgentResponse(string AgentId, string Token, string CafeId, string AgentName);
|
||||
|
||||
/// <summary>Ask online agents to scan the LAN for the given comma-separated TCP ports.</summary>
|
||||
public record ScanRequest(string? Ports);
|
||||
|
||||
public record ScannedDeviceDto(string Ip, int Port, string Kind);
|
||||
@@ -12,7 +12,9 @@ public record BranchPrintSettingsDto(
|
||||
string? ReceiptFooter,
|
||||
string? WifiPassword,
|
||||
string? PosDeviceIp,
|
||||
int? PosDevicePort);
|
||||
int? PosDevicePort,
|
||||
string? ReceiptPrintDeviceId,
|
||||
string? KitchenPrintDeviceId);
|
||||
|
||||
public record PatchBranchPrintSettingsRequest(
|
||||
string? ReceiptPrinterIp,
|
||||
@@ -25,7 +27,9 @@ public record PatchBranchPrintSettingsRequest(
|
||||
string? ReceiptFooter,
|
||||
string? WifiPassword,
|
||||
string? PosDeviceIp,
|
||||
int? PosDevicePort);
|
||||
int? PosDevicePort,
|
||||
string? ReceiptPrintDeviceId,
|
||||
string? KitchenPrintDeviceId);
|
||||
|
||||
public record PosPaymentRequest(string OrderId, decimal Amount);
|
||||
|
||||
|
||||
@@ -509,6 +509,45 @@ public class AuthService : IAuthService
|
||||
return (true, tokens, null, null, null);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithRecoveryKeyAsync(
|
||||
LoginWithRecoveryKeyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Key))
|
||||
return (false, null, "INVALID_KEY", "Invalid recovery key.");
|
||||
|
||||
var hash = RecoveryKeyGenerator.HashOf(request.Key);
|
||||
|
||||
// Exact-hash lookup — the unique index makes this a single index seek.
|
||||
var cafe = await _db.Cafes
|
||||
.FirstOrDefaultAsync(c => c.RecoveryKeyHash == hash && c.DeletedAt == null, cancellationToken);
|
||||
if (cafe is null)
|
||||
return (false, null, "INVALID_KEY", "Invalid recovery key.");
|
||||
|
||||
if (cafe.IsSuspended)
|
||||
return (false, null, "CAFE_SUSPENDED", "This café is suspended. Contact support.");
|
||||
|
||||
// The key authenticates as the café's Owner.
|
||||
var owner = await _db.Employees
|
||||
.Include(e => e.Cafe)
|
||||
.FirstOrDefaultAsync(
|
||||
e => e.CafeId == cafe.Id && e.Role == EmployeeRole.Owner && e.DeletedAt == null,
|
||||
cancellationToken);
|
||||
if (owner?.Cafe is null)
|
||||
return (false, null, "NO_OWNER", "This café has no owner account.");
|
||||
|
||||
_logger.LogWarning(
|
||||
"Recovery-key login for café {CafeId} as owner {OwnerId}", cafe.Id, owner.Id);
|
||||
|
||||
var membershipDtos = new List<CafeMembershipDto>
|
||||
{
|
||||
new(owner.CafeId, owner.Cafe.Name, owner.Role.ToString(), owner.Cafe.PlanTier.ToString())
|
||||
};
|
||||
|
||||
var tokens = await IssueTokensAsync(owner, owner.Cafe, membershipDtos, null, cancellationToken);
|
||||
return (true, tokens, null, null);
|
||||
}
|
||||
|
||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||
Core.Entities.Employee employee,
|
||||
Core.Entities.Cafe cafe,
|
||||
@@ -519,7 +558,18 @@ public class AuthService : IAuthService
|
||||
{
|
||||
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
|
||||
|
||||
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
|
||||
// Load custom role permissions when the employee has a custom role assigned.
|
||||
IReadOnlySet<Permission>? customPerms = null;
|
||||
if (!string.IsNullOrEmpty(employee.CustomRoleId))
|
||||
{
|
||||
var cr = await _db.CustomRoles
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Id == employee.CustomRoleId && r.CafeId == cafe.Id && r.DeletedAt == null, cancellationToken);
|
||||
if (cr != null)
|
||||
customPerms = CustomRolePermissions.Parse(cr.PermissionsJson);
|
||||
}
|
||||
|
||||
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId, customPerms);
|
||||
// On refresh, reuse the caller's refresh token (and slide its TTL below) instead
|
||||
// of minting a new one. A café often runs POS + KDS + queue display at once; if
|
||||
// refresh rotated the token, the first refresh would revoke it and every other
|
||||
@@ -541,8 +591,7 @@ public class AuthService : IAuthService
|
||||
TimeSpan.FromDays(refreshDays),
|
||||
cancellationToken);
|
||||
|
||||
var permissions = Meezi.Core.Authorization.RolePermissions
|
||||
.For(resolution.EffectiveRole)
|
||||
var permissions = (customPerms as IEnumerable<Permission> ?? RolePermissions.For(resolution.EffectiveRole))
|
||||
.Select(p => p.ToString())
|
||||
.OrderBy(p => p)
|
||||
.ToList();
|
||||
|
||||
@@ -40,6 +40,21 @@ public interface IBillingService
|
||||
string cafeId,
|
||||
string paymentId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Price a plan+months bundle and create a Pending FlatPay SubscriptionPayment
|
||||
/// (the "order"); the returned id is passed to the broker as client_ref / metadata.payment_id.</summary>
|
||||
Task<(string? PaymentId, decimal AmountToman, string? ErrorCode, string? Message)> CreateFlatPayOrderAsync(
|
||||
string cafeId,
|
||||
PlanTier tier,
|
||||
int months,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Grant a FlatPay order after the broker reports it Paid: activate the plan using
|
||||
/// the same coverage/queueing logic as the other providers. Idempotent.</summary>
|
||||
Task<bool> CompleteFlatPayAsync(
|
||||
string paymentId,
|
||||
string? refId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class BillingService : IBillingService
|
||||
@@ -217,6 +232,16 @@ public class BillingService : IBillingService
|
||||
|
||||
payment.RefId = verify.RefId;
|
||||
|
||||
await ActivatePaymentAsync(payment, cancellationToken);
|
||||
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
}
|
||||
|
||||
/// <summary>Apply a paid SubscriptionPayment: book it after the current coverage (queued) or
|
||||
/// activate it now, update the cafe plan, persist, and send the confirmation SMS. Shared by all
|
||||
/// providers (gateway callbacks and the FlatPay webhook).</summary>
|
||||
private async Task ActivatePaymentAsync(SubscriptionPayment payment, CancellationToken cancellationToken)
|
||||
{
|
||||
var cafe = payment.Cafe;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
@@ -244,8 +269,75 @@ public class BillingService : IBillingService
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
|
||||
}
|
||||
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
public async Task<(string? PaymentId, decimal AmountToman, string? ErrorCode, string? Message)> CreateFlatPayOrderAsync(
|
||||
string cafeId,
|
||||
PlanTier tier,
|
||||
int months,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (months is < 1 or > 36)
|
||||
return (null, 0m, "INVALID_MONTHS", "Months must be between 1 and 36.");
|
||||
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null)
|
||||
return (null, 0m, "NOT_FOUND", "Cafe not found.");
|
||||
|
||||
if (!await _platformCatalog.IsBillableOnlineAsync(tier, cancellationToken))
|
||||
return (null, 0m, "NOT_BILLABLE", "This plan requires contacting sales.");
|
||||
|
||||
var monthly = await _platformCatalog.GetMonthlyPriceTomanAsync(tier, cancellationToken);
|
||||
if (monthly <= 0)
|
||||
return (null, 0m, "NOT_BILLABLE", "This plan has no online price.");
|
||||
|
||||
var amountToman = monthly * months;
|
||||
var payment = new SubscriptionPayment
|
||||
{
|
||||
CafeId = cafeId,
|
||||
PlanTier = tier,
|
||||
Months = months,
|
||||
AmountToman = amountToman,
|
||||
AmountRials = PlanPricing.ToRials(amountToman),
|
||||
Provider = PaymentProvider.FlatPay,
|
||||
Status = SubscriptionPaymentStatus.Pending,
|
||||
};
|
||||
|
||||
_db.SubscriptionPayments.Add(payment);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (payment.Id, amountToman, null, null);
|
||||
}
|
||||
|
||||
public async Task<bool> CompleteFlatPayAsync(
|
||||
string paymentId,
|
||||
string? refId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(paymentId))
|
||||
return false;
|
||||
|
||||
var payment = await _db.SubscriptionPayments
|
||||
.Include(p => p.Cafe)
|
||||
.FirstOrDefaultAsync(
|
||||
p => p.Id == paymentId && p.Provider == PaymentProvider.FlatPay,
|
||||
cancellationToken);
|
||||
|
||||
if (payment is null)
|
||||
{
|
||||
_logger.LogWarning("FlatPay grant: no pending order {PaymentId}", paymentId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Already granted (webhook redelivery / double-process) → idempotent no-op.
|
||||
if (payment.Status is SubscriptionPaymentStatus.Completed or SubscriptionPaymentStatus.Scheduled)
|
||||
return true;
|
||||
|
||||
payment.RefId = refId;
|
||||
await ActivatePaymentAsync(payment, cancellationToken);
|
||||
_logger.LogInformation("FlatPay grant applied: payment {PaymentId} → {Tier} x{Months}m",
|
||||
payment.Id, payment.PlanTier, payment.Months);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
|
||||
@@ -379,12 +471,7 @@ public class BillingService : IBillingService
|
||||
|
||||
var maxOrders = PlanLimits.MaxOrdersPerDay(cafe.PlanTier);
|
||||
var maxCustomers = PlanLimits.MaxCustomers(cafe.PlanTier);
|
||||
var maxSms = PlanLimits.MaxSmsPerMonth(cafe.PlanTier);
|
||||
|
||||
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
|
||||
var redis = _redis.GetDatabase();
|
||||
var smsUsed = await redis.StringGetAsync(monthKey);
|
||||
var smsUsedCount = smsUsed.HasValue ? (int)smsUsed : 0;
|
||||
|
||||
var menu3d = await _platformCatalog.IsFeatureEnabledForCafeAsync(
|
||||
cafeId, cafe.PlanTier, FeatureMenu3d, cancellationToken);
|
||||
@@ -406,8 +493,6 @@ public class BillingService : IBillingService
|
||||
maxOrders == int.MaxValue ? null : maxOrders,
|
||||
customersCount,
|
||||
maxCustomers == int.MaxValue ? null : maxCustomers,
|
||||
smsUsedCount,
|
||||
maxSms == int.MaxValue ? -1 : maxSms,
|
||||
menu3d,
|
||||
menuAi3d,
|
||||
ai3dUsedCount,
|
||||
|
||||
@@ -33,7 +33,9 @@ public class DemoSeedService : IDemoSeedService
|
||||
// 1. Ensure 9% default tax
|
||||
var taxId = $"{cafeId}_demo_tax";
|
||||
var taxCreated = false;
|
||||
if (!await _db.Taxes.AnyAsync(t => t.CafeId == cafeId && t.IsDefault, ct))
|
||||
// IgnoreQueryFilters: soft-deleted rows still occupy the PK; re-seeding
|
||||
// after a user deletes demo data must see those rows to avoid a PK collision.
|
||||
if (!await _db.Taxes.IgnoreQueryFilters().AnyAsync(t => t.CafeId == cafeId && t.IsDefault, ct))
|
||||
{
|
||||
_db.Taxes.Add(new Tax
|
||||
{
|
||||
@@ -51,6 +53,7 @@ public class DemoSeedService : IDemoSeedService
|
||||
else
|
||||
{
|
||||
taxId = await _db.Taxes
|
||||
.IgnoreQueryFilters()
|
||||
.Where(t => t.CafeId == cafeId && t.IsDefault)
|
||||
.Select(t => t.Id)
|
||||
.FirstAsync(ct);
|
||||
|
||||
@@ -20,6 +20,11 @@ public interface IAuthService
|
||||
LoginWithPasswordRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Log in the café Owner using an admin-issued permanent recovery key.</summary>
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithRecoveryKeyAsync(
|
||||
LoginWithRecoveryKeyRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
|
||||
string employeeId, string targetCafeId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
@@ -11,8 +12,15 @@ public interface IJwtTokenService
|
||||
/// Issue a token scoped to an active branch. The <paramref name="effectiveRole"/>
|
||||
/// is the role the employee holds in <paramref name="activeBranchId"/> (or their
|
||||
/// café-wide role when <paramref name="activeBranchId"/> is null).
|
||||
/// When <paramref name="customPermissions"/> is non-null the token embeds those
|
||||
/// permissions as a claim that overrides the role matrix on the server side.
|
||||
/// </summary>
|
||||
string CreateAccessToken(Employee employee, Cafe cafe, EmployeeRole effectiveRole, string? activeBranchId);
|
||||
string CreateAccessToken(
|
||||
Employee employee,
|
||||
Cafe cafe,
|
||||
EmployeeRole effectiveRole,
|
||||
string? activeBranchId,
|
||||
IEnumerable<Permission>? customPermissions = null);
|
||||
|
||||
string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa");
|
||||
string CreateRefreshToken();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Meezi.Core.Authorization;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
@@ -21,7 +22,12 @@ public class JwtTokenService : IJwtTokenService
|
||||
public string CreateAccessToken(Employee employee, Cafe cafe) =>
|
||||
CreateAccessToken(employee, cafe, employee.Role, employee.BranchId);
|
||||
|
||||
public string CreateAccessToken(Employee employee, Cafe cafe, EmployeeRole effectiveRole, string? activeBranchId)
|
||||
public string CreateAccessToken(
|
||||
Employee employee,
|
||||
Cafe cafe,
|
||||
EmployeeRole effectiveRole,
|
||||
string? activeBranchId,
|
||||
IEnumerable<Permission>? customPermissions = null)
|
||||
{
|
||||
var key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
|
||||
var issuer = _configuration["Jwt:Issuer"] ?? "meezi";
|
||||
@@ -41,6 +47,13 @@ public class JwtTokenService : IJwtTokenService
|
||||
if (!string.IsNullOrEmpty(activeBranchId))
|
||||
claims.Add(new Claim(MeeziClaimTypes.BranchId, activeBranchId));
|
||||
|
||||
if (customPermissions != null)
|
||||
{
|
||||
var encoded = string.Join(",", customPermissions.Select(p => p.ToString()));
|
||||
if (!string.IsNullOrEmpty(encoded))
|
||||
claims.Add(new Claim(MeeziClaimTypes.CustomPermissions, encoded));
|
||||
}
|
||||
|
||||
var credentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
@@ -33,12 +33,13 @@ public class KitchenStationService : IKitchenStationService
|
||||
s.PrinterIp,
|
||||
s.PrinterPort,
|
||||
s.SortOrder,
|
||||
s.PrintDeviceId,
|
||||
CategoryCount = s.Categories.Count(c => c.DeletedAt == null)
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return stations.Select(s => new KitchenStationDto(
|
||||
s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount)).ToList();
|
||||
s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount, s.PrintDeviceId)).ToList();
|
||||
}
|
||||
|
||||
public async Task<KitchenStationDto?> CreateAsync(
|
||||
@@ -60,6 +61,7 @@ public class KitchenStationService : IKitchenStationService
|
||||
Name = request.Name.Trim(),
|
||||
PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(),
|
||||
PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100,
|
||||
PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId,
|
||||
SortOrder = request.SortOrder
|
||||
};
|
||||
|
||||
@@ -95,6 +97,8 @@ public class KitchenStationService : IKitchenStationService
|
||||
entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim();
|
||||
if (request.PrinterPort.HasValue)
|
||||
entity.PrinterPort = request.PrinterPort.Value > 0 ? request.PrinterPort.Value : 9100;
|
||||
if (request.PrintDeviceId is not null)
|
||||
entity.PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId;
|
||||
if (request.SortOrder.HasValue)
|
||||
entity.SortOrder = request.SortOrder.Value;
|
||||
|
||||
@@ -114,6 +118,12 @@ public class KitchenStationService : IKitchenStationService
|
||||
foreach (var cat in categories)
|
||||
cat.KitchenStationId = null;
|
||||
|
||||
var items = await _db.MenuItems
|
||||
.Where(i => i.KitchenStationId == id && i.CafeId == cafeId)
|
||||
.ToListAsync(ct);
|
||||
foreach (var item in items)
|
||||
item.KitchenStationId = null;
|
||||
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
@@ -131,12 +141,13 @@ public class KitchenStationService : IKitchenStationService
|
||||
x.PrinterIp,
|
||||
x.PrinterPort,
|
||||
x.SortOrder,
|
||||
x.PrintDeviceId,
|
||||
CategoryCount = x.Categories.Count(c => c.DeletedAt == null)
|
||||
})
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return s is null
|
||||
? null
|
||||
: new KitchenStationDto(s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount);
|
||||
: new KitchenStationDto(s.Id, s.BranchId, s.Name, s.PrinterIp, s.PrinterPort, s.SortOrder, s.CategoryCount, s.PrintDeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,8 @@ public class MenuService : IMenuService
|
||||
ImageUrl = imageUrl,
|
||||
VideoUrl = request.VideoUrl,
|
||||
Model3dUrl = NormalizeOptionalText(request.Model3dUrl),
|
||||
IsAvailable = request.IsAvailable
|
||||
IsAvailable = request.IsAvailable,
|
||||
KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId) ? null : request.KitchenStationId,
|
||||
};
|
||||
|
||||
_db.MenuItems.Add(entity);
|
||||
@@ -178,6 +179,8 @@ public class MenuService : IMenuService
|
||||
if (request.Model3dUrl is not null)
|
||||
entity.Model3dUrl = string.IsNullOrWhiteSpace(request.Model3dUrl) ? null : request.Model3dUrl.Trim();
|
||||
if (request.IsAvailable.HasValue) entity.IsAvailable = request.IsAvailable.Value;
|
||||
if (request.KitchenStationId is not null)
|
||||
entity.KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId) ? null : request.KitchenStationId;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToItemDto(entity);
|
||||
@@ -236,5 +239,6 @@ public class MenuService : IMenuService
|
||||
MenuItemImageDefaults.ResolveDisplayImageUrl(i),
|
||||
i.VideoUrl,
|
||||
i.Model3dUrl,
|
||||
i.IsAvailable);
|
||||
i.IsAvailable,
|
||||
i.KitchenStationId);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,19 @@ public interface IOrderService
|
||||
RecordPaymentsRequest request,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<OrderDto> Items, int Total)> GetClosedOrdersAsync(
|
||||
string cafeId,
|
||||
DateOnly date,
|
||||
string? branchId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<OrderServiceResult<OrderDto>> CorrectPaymentsAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
CorrectPaymentsRequest request,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class OrderService : IOrderService
|
||||
@@ -159,6 +172,8 @@ public class OrderService : IOrderService
|
||||
var orders = await _db.Orders
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(i => i.MenuItem)
|
||||
.ThenInclude(m => m.Category)
|
||||
.ThenInclude(c => c.KitchenStation)
|
||||
.Include(o => o.Table)
|
||||
.Where(o => o.CafeId == cafeId && LiveStatuses.Contains(o.Status))
|
||||
.OrderBy(o => o.CreatedAt)
|
||||
@@ -980,9 +995,18 @@ public class OrderService : IOrderService
|
||||
if (order.Status == OrderStatus.Cancelled)
|
||||
return new OrderServiceResult<OrderDto>(false, null, "ORDER_ALREADY_CANCELLED");
|
||||
|
||||
if (!OpenForPaymentStatuses.Contains(order.Status))
|
||||
if (order.Status == OrderStatus.Delivered)
|
||||
return new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_OPEN");
|
||||
|
||||
// Integrity / anti-fraud: once the kitchen has acted on the order
|
||||
// (Confirmed / Preparing / Ready) the food has been produced, so the order
|
||||
// can no longer be cancelled/deleted — otherwise a cashier could fire an
|
||||
// order, take cash without recording a payment, then erase it. Only a
|
||||
// not-yet-started (Pending) order may be cancelled; a started one must be
|
||||
// completed (and refunded via the audited refund flow if needed).
|
||||
if (order.Status != OrderStatus.Pending)
|
||||
return new OrderServiceResult<OrderDto>(false, null, "ORDER_IN_PREPARATION");
|
||||
|
||||
// A paid order must be refunded through the payment flow first — cancelling it
|
||||
// here would silently strip the recorded money. Block and surface the reason.
|
||||
if (order.Payments.Any(p => p.DeletedAt == null))
|
||||
@@ -1024,6 +1048,12 @@ public class OrderService : IOrderService
|
||||
if (order is null)
|
||||
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "ORDER_NOT_FOUND");
|
||||
|
||||
// Never take payment on an already-closed order — a double-tap on Pay, or
|
||||
// paying a closed order reopened from the board, would otherwise record
|
||||
// duplicate payments, re-earn loyalty, reprint, and overstate the drawer.
|
||||
if (order.Status is OrderStatus.Delivered or OrderStatus.Cancelled)
|
||||
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "ORDER_ALREADY_CLOSED");
|
||||
|
||||
var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken);
|
||||
if (string.IsNullOrEmpty(branchId))
|
||||
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "NO_OPEN_SHIFT", "branchId");
|
||||
@@ -1110,7 +1140,8 @@ public class OrderService : IOrderService
|
||||
|
||||
if (paidTotal >= order.Total)
|
||||
{
|
||||
PrinterBackgroundJobs.QueueReceiptPrint(_scopeFactory, cafeId, orderId);
|
||||
// Receipt is printed explicitly from the POS success sheet (single
|
||||
// print path) — no auto-print here, to avoid a duplicate receipt.
|
||||
await _loyalty.ApplyEarnOnOrderPaidAsync(cafeId, order.CustomerId, paidTotal, cancellationToken);
|
||||
await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Delivered, cancellationToken);
|
||||
}
|
||||
@@ -1119,6 +1150,117 @@ public class OrderService : IOrderService
|
||||
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(true, dtos);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<OrderDto> Items, int Total)> GetClosedOrdersAsync(
|
||||
string cafeId,
|
||||
DateOnly date,
|
||||
string? branchId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (utcStart, utcEnd) = IranCalendar.GetUtcRangeForIranDay(date);
|
||||
|
||||
var query = _db.Orders
|
||||
.Where(o => o.CafeId == cafeId
|
||||
&& (o.Status == OrderStatus.Delivered || o.Status == OrderStatus.Cancelled)
|
||||
&& o.CreatedAt >= utcStart
|
||||
&& o.CreatedAt < utcEnd);
|
||||
|
||||
if (!string.IsNullOrEmpty(branchId))
|
||||
query = query.Where(o => o.BranchId == branchId);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
var orders = await ApplyOrderIncludes(query)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (orders.Select(MapOrder).ToList(), total);
|
||||
}
|
||||
|
||||
public async Task<OrderServiceResult<OrderDto>> CorrectPaymentsAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
CorrectPaymentsRequest request,
|
||||
string? userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var order = await LoadOrderAsync(cafeId, orderId, cancellationToken);
|
||||
if (order is null)
|
||||
return new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_FOUND");
|
||||
|
||||
// Resolve the payments being voided — they must belong to this order and
|
||||
// still be live. Payments are never deleted; voiding marks them Refunded
|
||||
// so the original سند stays visible in history and audit.
|
||||
var toVoid = new List<Payment>();
|
||||
foreach (var paymentId in request.VoidPaymentIds.Distinct())
|
||||
{
|
||||
var payment = order.Payments.FirstOrDefault(p => p.Id == paymentId);
|
||||
if (payment is null)
|
||||
return new OrderServiceResult<OrderDto>(false, null, "PAYMENT_NOT_FOUND", "voidPaymentIds");
|
||||
if (payment.Status != PaymentStatus.Completed)
|
||||
return new OrderServiceResult<OrderDto>(false, null, "PAYMENT_ALREADY_REFUNDED", "voidPaymentIds");
|
||||
toVoid.Add(payment);
|
||||
}
|
||||
|
||||
var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken);
|
||||
if (string.IsNullOrEmpty(branchId))
|
||||
return new OrderServiceResult<OrderDto>(false, null, "NO_OPEN_SHIFT", "branchId");
|
||||
|
||||
// Corrections move money through the drawer, so they need an open shift
|
||||
// exactly like recording a payment does.
|
||||
var shiftCheck = await _shiftService.RequireOpenShiftForBranchAsync(cafeId, branchId, cancellationToken);
|
||||
if (!shiftCheck.Success)
|
||||
return new OrderServiceResult<OrderDto>(false, null, shiftCheck.ErrorCode, shiftCheck.Field);
|
||||
var openShift = shiftCheck.Data!;
|
||||
|
||||
foreach (var payment in toVoid)
|
||||
payment.Status = PaymentStatus.Refunded;
|
||||
|
||||
var replacements = request.Replacements.Select(p => new Payment
|
||||
{
|
||||
OrderId = orderId,
|
||||
Method = p.Method,
|
||||
Amount = p.Amount,
|
||||
Reference = p.Reference,
|
||||
Status = PaymentStatus.Completed
|
||||
}).ToList();
|
||||
_db.Payments.AddRange(replacements);
|
||||
|
||||
// Fully paid again after the correction → ensure the order is closed;
|
||||
// underpaid → leave the status alone (the remainder can be collected
|
||||
// through the normal payment flow later). EF navigation fixup may have
|
||||
// already appended the replacements to order.Payments, so exclude them
|
||||
// by reference to avoid double-counting.
|
||||
var paidTotal = order.Payments
|
||||
.Where(p => p.Status == PaymentStatus.Completed && !replacements.Contains(p))
|
||||
.Sum(p => p.Amount)
|
||||
+ replacements.Sum(p => p.Amount);
|
||||
if (paidTotal >= order.Total && OpenForPaymentStatuses.Contains(order.Status))
|
||||
order.Status = OrderStatus.Delivered;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var createdBy = userId ?? openShift.OpenedByUserId;
|
||||
foreach (var payment in toVoid)
|
||||
{
|
||||
await _shiftService.RecordTransactionAsync(
|
||||
cafeId, openShift.Id, CashTransactionType.Refund, payment.Method,
|
||||
payment.Amount, createdBy, orderId, request.Reason, cancellationToken);
|
||||
}
|
||||
foreach (var payment in replacements)
|
||||
{
|
||||
await _shiftService.RecordTransactionAsync(
|
||||
cafeId, openShift.Id, CashTransactionType.OrderPayment, payment.Method,
|
||||
payment.Amount, createdBy, orderId, request.Reason, cancellationToken);
|
||||
}
|
||||
|
||||
return new OrderServiceResult<OrderDto>(true, MapOrder(order));
|
||||
}
|
||||
|
||||
private static IQueryable<Order> ApplyOrderIncludes(IQueryable<Order> query) =>
|
||||
query
|
||||
.Include(o => o.Items)
|
||||
@@ -1221,6 +1363,8 @@ public class OrderService : IOrderService
|
||||
i.UnitPrice,
|
||||
i.Notes,
|
||||
i.IsVoided,
|
||||
i.VoidedAt)).ToList(),
|
||||
i.VoidedAt,
|
||||
i.MenuItem?.Category?.KitchenStationId,
|
||||
i.MenuItem?.Category?.KitchenStation?.Name)).ToList(),
|
||||
o.Source);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Meezi.API.Services.Payments;
|
||||
|
||||
public sealed class FlatPayOptions
|
||||
{
|
||||
public const string SectionName = "FlatPay";
|
||||
|
||||
public string ApiKey { get; set; } = "";
|
||||
public string Secret { get; set; } = "";
|
||||
public string BaseUrl { get; set; } = "https://pay.flatrender.ir";
|
||||
public string ReturnUrl { get; set; } = "https://meezi.ir/payment/return";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client for the FlatRender Pay broker (a ZarinPal front). Requests are authenticated
|
||||
/// with <c>X-Api-Key</c> + <c>X-Signature</c> = hex(HMAC-SHA256(secret, raw JSON bytes));
|
||||
/// webhooks are verified the same way. The signature is computed over the EXACT bytes
|
||||
/// that are sent/received, so we serialize once and reuse the buffer.
|
||||
/// </summary>
|
||||
public interface IFlatPayService
|
||||
{
|
||||
/// <summary>Create a payment at the broker and return its hosted payment URL (null on failure).
|
||||
/// <paramref name="clientRef"/> is echoed back and also embedded in metadata.payment_id.</summary>
|
||||
Task<string?> RequestAsync(
|
||||
string userId, string productId, long amountToman, string description, string clientRef,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Fixed-time compare hex(HMAC(secret, rawBytes)) against the webhook signature header.</summary>
|
||||
bool VerifyWebhook(byte[] rawBytes, string? signature);
|
||||
|
||||
/// <summary>Idempotency: true only the first time a given broker payment id is seen.</summary>
|
||||
bool TryMarkProcessed(string id);
|
||||
}
|
||||
|
||||
public sealed class FlatPayService : IFlatPayService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly FlatPayOptions _opts;
|
||||
private readonly ILogger<FlatPayService> _logger;
|
||||
|
||||
// Webhooks can be redelivered; remember the broker ids we've already granted.
|
||||
private readonly ConcurrentDictionary<string, byte> _seen = new();
|
||||
|
||||
public FlatPayService(HttpClient http, IOptions<FlatPayOptions> opts, ILogger<FlatPayService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_opts = opts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string?> RequestAsync(
|
||||
string userId, string productId, long amountToman, string description, string clientRef,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var body = new PayRequestBody(
|
||||
amountToman,
|
||||
"IRT",
|
||||
description,
|
||||
clientRef,
|
||||
_opts.ReturnUrl,
|
||||
new PayMetadata(userId, productId, clientRef));
|
||||
|
||||
// Serialize once: these exact bytes are both signed and sent.
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(body);
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "/v1/pay/request");
|
||||
req.Content = new ByteArrayContent(bytes);
|
||||
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
req.Headers.TryAddWithoutValidation("X-Api-Key", _opts.ApiKey);
|
||||
req.Headers.TryAddWithoutValidation("X-Signature", Sign(bytes));
|
||||
|
||||
try
|
||||
{
|
||||
using var resp = await _http.SendAsync(req, ct);
|
||||
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("FlatPay /v1/pay/request failed {Status}: {Body}", (int)resp.StatusCode, respBody);
|
||||
return null;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(respBody);
|
||||
var url = ExtractPaymentUrl(doc.RootElement);
|
||||
if (string.IsNullOrEmpty(url))
|
||||
_logger.LogError("FlatPay request returned no payment_url: {Body}", respBody);
|
||||
return url;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "FlatPay request error");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyWebhook(byte[] rawBytes, string? signature)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signature)) return false;
|
||||
var expected = Sign(rawBytes);
|
||||
var provided = signature.Trim().ToLowerInvariant();
|
||||
|
||||
// Compare the ascii hex digests in fixed time.
|
||||
var a = Encoding.ASCII.GetBytes(expected);
|
||||
var b = Encoding.ASCII.GetBytes(provided);
|
||||
return a.Length == b.Length && CryptographicOperations.FixedTimeEquals(a, b);
|
||||
}
|
||||
|
||||
public bool TryMarkProcessed(string id) =>
|
||||
!string.IsNullOrEmpty(id) && _seen.TryAdd(id, 0);
|
||||
|
||||
private string Sign(byte[] body)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_opts.Secret));
|
||||
return Convert.ToHexString(hmac.ComputeHash(body)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? ExtractPaymentUrl(JsonElement root)
|
||||
{
|
||||
if (TryGetString(root, "payment_url") is { } direct) return direct;
|
||||
// Some broker responses nest the result under "data".
|
||||
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
|
||||
return TryGetString(data, "payment_url");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement el, string name) =>
|
||||
el.ValueKind == JsonValueKind.Object
|
||||
&& el.TryGetProperty(name, out var v)
|
||||
&& v.ValueKind == JsonValueKind.String
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private sealed record PayRequestBody(
|
||||
[property: JsonPropertyName("amount")] long Amount,
|
||||
[property: JsonPropertyName("currency")] string Currency,
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
[property: JsonPropertyName("client_ref")] string ClientRef,
|
||||
[property: JsonPropertyName("return_url")] string ReturnUrl,
|
||||
[property: JsonPropertyName("metadata")] PayMetadata Metadata);
|
||||
|
||||
private sealed record PayMetadata(
|
||||
[property: JsonPropertyName("user_id")] string UserId,
|
||||
[property: JsonPropertyName("product_id")] string ProductId,
|
||||
[property: JsonPropertyName("payment_id")] string PaymentId);
|
||||
}
|
||||
@@ -114,26 +114,9 @@ public class PlanLimitChecker : IPlanLimitChecker
|
||||
}
|
||||
}
|
||||
|
||||
var smsCampaignPath = $"/api/cafes/{cafeId}/sms/campaign";
|
||||
if (path.Equals(smsCampaignPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{smsCampaignPath}/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var limitsSms = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
|
||||
var maxSms = limitsSms.MaxSmsPerMonth;
|
||||
if (maxSms == 0)
|
||||
return (false, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan. Please upgrade.");
|
||||
|
||||
if (maxSms == int.MaxValue)
|
||||
return (true, null, null);
|
||||
|
||||
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
|
||||
var redis = _redis.GetDatabase();
|
||||
var used = await redis.StringGetAsync(monthKey);
|
||||
var usedCount = used.HasValue ? (int)used : 0;
|
||||
|
||||
if (usedCount >= maxSms)
|
||||
return (false, "PLAN_LIMIT_REACHED", "Monthly SMS limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
// NOTE: SMS is deliberately NOT plan-gated — marketing SMS is
|
||||
// bring-your-own-provider (the café's own API key + sender line), so the
|
||||
// café's provider account is the only limit.
|
||||
|
||||
return (true, null, null);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Meezi.API.Models.Printing;
|
||||
using Meezi.API.Services.Printing;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -29,15 +30,18 @@ public class PosDeviceService : IPosDeviceService
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IPrintAgentRegistry _agents;
|
||||
private readonly ILogger<PosDeviceService> _logger;
|
||||
|
||||
public PosDeviceService(
|
||||
AppDbContext db,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IPrintAgentRegistry agents,
|
||||
ILogger<PosDeviceService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_agents = agents;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -71,14 +75,31 @@ public class PosDeviceService : IPosDeviceService
|
||||
if (order is null)
|
||||
return PosDeviceResult.Fail("ORDER_NOT_FOUND");
|
||||
|
||||
var amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero);
|
||||
var ip = branch.PosDeviceIp!.Trim();
|
||||
|
||||
// Prefer relaying through a local print agent on the café LAN — the cloud
|
||||
// can't reach the terminal's private IP directly (same reason the agent
|
||||
// exists for printers). Fall back to a direct call only on-prem / when no
|
||||
// agent is connected.
|
||||
var agentId = await ResolveOnlineAgentAsync(cafeId, branchId, ct);
|
||||
if (agentId is not null)
|
||||
{
|
||||
var outcome = await _agents.SendPaymentAsync(agentId, ip, port, amount, request.OrderId, ct);
|
||||
if (outcome.Success)
|
||||
return PosDeviceResult.Ok();
|
||||
_logger.LogWarning("Agent-relayed POS payment failed ({Agent}): {Error}", agentId, outcome.Error);
|
||||
return PosDeviceResult.Fail(MapAgentError(outcome.Error), outcome.Error);
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero),
|
||||
amount,
|
||||
orderId = request.OrderId,
|
||||
branchId,
|
||||
};
|
||||
|
||||
var url = $"http://{branch.PosDeviceIp!.Trim()}:{port}/pay";
|
||||
var url = $"http://{ip}:{port}/pay";
|
||||
|
||||
try
|
||||
{
|
||||
@@ -117,4 +138,31 @@ public class PosDeviceService : IPosDeviceService
|
||||
return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The online agent best placed to reach this branch's terminal — one
|
||||
/// bound to the branch if present, else any online agent of the café.</summary>
|
||||
private async Task<string?> ResolveOnlineAgentAsync(string cafeId, string branchId, CancellationToken ct)
|
||||
{
|
||||
var online = _agents.OnlineAgentIdsForCafe(cafeId);
|
||||
if (online.Count == 0) return null;
|
||||
|
||||
var agents = await _db.PrintAgents
|
||||
.AsNoTracking()
|
||||
.Where(a => a.CafeId == cafeId && !a.Revoked && a.DeletedAt == null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return agents.FirstOrDefault(a => a.BranchId == branchId && online.Contains(a.Id))?.Id
|
||||
?? agents.FirstOrDefault(a => online.Contains(a.Id))?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Normalize an agent-relay error string back to a POS_DEVICE_* code.</summary>
|
||||
private static string MapAgentError(string? error) => error switch
|
||||
{
|
||||
null or "" => "POS_DEVICE_FAILED",
|
||||
var e when e.Contains("TIMEOUT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_TIMEOUT",
|
||||
var e when e.StartsWith("POS_DEVICE_", StringComparison.Ordinal) => e.Split(':')[0],
|
||||
var e when e.Contains("REJECT", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_REJECTED",
|
||||
var e when e.Contains("OFFLINE", StringComparison.OrdinalIgnoreCase) => "POS_DEVICE_CONNECTION_FAILED",
|
||||
_ => "POS_DEVICE_CONNECTION_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ public interface IPrinterService
|
||||
Task<PrintResult> PrintKitchenTicketAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
string? stationId = null,
|
||||
CancellationToken ct = default);
|
||||
Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default);
|
||||
Task<PrintResult> TestPrintDeviceAsync(string cafeId, string deviceId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class NetworkPrinterService : IPrinterService
|
||||
@@ -28,17 +30,20 @@ public class NetworkPrinterService : IPrinterService
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IOrderService _orders;
|
||||
private readonly ReceiptBuilder _receiptBuilder;
|
||||
private readonly IPrintAgentRegistry _agents;
|
||||
private readonly ILogger<NetworkPrinterService> _logger;
|
||||
|
||||
public NetworkPrinterService(
|
||||
AppDbContext db,
|
||||
IOrderService orders,
|
||||
ReceiptBuilder receiptBuilder,
|
||||
IPrintAgentRegistry agents,
|
||||
ILogger<NetworkPrinterService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_orders = orders;
|
||||
_receiptBuilder = receiptBuilder;
|
||||
_agents = agents;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -48,13 +53,16 @@ public class NetworkPrinterService : IPrinterService
|
||||
if (ctx is null)
|
||||
return PrintResult.Fail("ORDER_NOT_FOUND");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ctx.Value.branch.ReceiptPrinterIp))
|
||||
var branch = ctx.Value.branch;
|
||||
if (string.IsNullOrWhiteSpace(branch.ReceiptPrintDeviceId) && string.IsNullOrWhiteSpace(branch.ReceiptPrinterIp))
|
||||
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
|
||||
|
||||
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
|
||||
return await SendToPrinterAsync(
|
||||
ctx.Value.branch.ReceiptPrinterIp!,
|
||||
ctx.Value.branch.ReceiptPrinterPort ?? 9100,
|
||||
return await DispatchAsync(
|
||||
cafeId,
|
||||
branch.ReceiptPrintDeviceId,
|
||||
branch.ReceiptPrinterIp,
|
||||
branch.ReceiptPrinterPort ?? 9100,
|
||||
bytes,
|
||||
ct);
|
||||
}
|
||||
@@ -62,6 +70,7 @@ public class NetworkPrinterService : IPrinterService
|
||||
public async Task<PrintResult> PrintKitchenTicketAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
string? stationId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var ctx = await BuildContextAsync(cafeId, orderId, ct);
|
||||
@@ -74,15 +83,16 @@ public class NetworkPrinterService : IPrinterService
|
||||
return PrintResult.Ok();
|
||||
|
||||
var menuItemIds = activeItems.Select(i => i.MenuItemId).Distinct().ToList();
|
||||
var categoryStations = await (
|
||||
// Per-item station overrides the category's station; fall back to category.
|
||||
var itemStations = await (
|
||||
from m in _db.MenuItems.AsNoTracking()
|
||||
join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id
|
||||
where menuItemIds.Contains(m.Id) && m.CafeId == cafeId
|
||||
select new { m.Id, c.KitchenStationId }
|
||||
select new { m.Id, StationId = m.KitchenStationId ?? c.KitchenStationId }
|
||||
).ToListAsync(ct);
|
||||
|
||||
var stationIds = categoryStations
|
||||
.Select(x => x.KitchenStationId)
|
||||
var stationIds = itemStations
|
||||
.Select(x => x.StationId)
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
@@ -97,11 +107,19 @@ public class NetworkPrinterService : IPrinterService
|
||||
var groups = activeItems
|
||||
.GroupBy(item =>
|
||||
{
|
||||
var cat = categoryStations.FirstOrDefault(c => c.Id == item.MenuItemId);
|
||||
return cat?.KitchenStationId;
|
||||
var map = itemStations.FirstOrDefault(c => c.Id == item.MenuItemId);
|
||||
return map?.StationId;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Optionally reprint a single station only (e.g. just the bar ticket).
|
||||
if (!string.IsNullOrEmpty(stationId))
|
||||
{
|
||||
groups = groups.Where(g => g.Key == stationId).ToList();
|
||||
if (groups.Count == 0)
|
||||
return PrintResult.Fail("NO_STATION_ITEMS");
|
||||
}
|
||||
|
||||
PrintResult? lastFail = null;
|
||||
var anyPrinted = false;
|
||||
|
||||
@@ -111,18 +129,21 @@ public class NetworkPrinterService : IPrinterService
|
||||
? null
|
||||
: stations.FirstOrDefault(s => s.Id == group.Key);
|
||||
|
||||
string? deviceId;
|
||||
string? ip;
|
||||
int port;
|
||||
string? stationLabel = null;
|
||||
|
||||
if (station is not null && !string.IsNullOrWhiteSpace(station.PrinterIp))
|
||||
if (station is not null && (!string.IsNullOrWhiteSpace(station.PrintDeviceId) || !string.IsNullOrWhiteSpace(station.PrinterIp)))
|
||||
{
|
||||
deviceId = station.PrintDeviceId;
|
||||
ip = station.PrinterIp;
|
||||
port = station.PrinterPort;
|
||||
stationLabel = station.Name;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
|
||||
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrintDeviceId) || !string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
|
||||
{
|
||||
deviceId = ctx.Value.branch.KitchenPrintDeviceId;
|
||||
ip = ctx.Value.branch.KitchenPrinterIp;
|
||||
port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
|
||||
}
|
||||
@@ -136,7 +157,7 @@ public class NetworkPrinterService : IPrinterService
|
||||
var bytes = _receiptBuilder.BuildKitchenTicket(
|
||||
ctx.Value.printCtx with { StationName = stationLabel },
|
||||
itemsOnly);
|
||||
var result = await SendToPrinterAsync(ip!, port, bytes, ct);
|
||||
var result = await DispatchAsync(cafeId, deviceId, ip, port, bytes, ct);
|
||||
if (result.Success)
|
||||
anyPrinted = true;
|
||||
else
|
||||
@@ -155,6 +176,66 @@ public class NetworkPrinterService : IPrinterService
|
||||
return await SendToPrinterAsync(printerIp.Trim(), port, bytes, ct);
|
||||
}
|
||||
|
||||
public async Task<PrintResult> TestPrintDeviceAsync(string cafeId, string deviceId, CancellationToken ct = default)
|
||||
{
|
||||
var device = await _db.PrintDevices.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.Id == deviceId && d.CafeId == cafeId, ct);
|
||||
if (device is null)
|
||||
return PrintResult.Fail("DEVICE_NOT_FOUND");
|
||||
if (!_agents.IsOnline(device.AgentId))
|
||||
return PrintResult.Fail("AGENT_OFFLINE");
|
||||
|
||||
var bytes = _receiptBuilder.BuildTestPage();
|
||||
var outcome = await _agents.SendJobAsync(device.AgentId, new PrintJobRequest(device.SystemName, bytes), ct);
|
||||
return outcome.Success ? PrintResult.Ok() : PrintResult.Fail("AGENT_PRINT_FAILED", outcome.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send bytes to a printer, preferring a local print agent when one is mapped and
|
||||
/// online (the only way to reach a LAN/USB printer from the cloud); otherwise fall
|
||||
/// back to a direct TCP connection (on-prem deployments / reachable printers).
|
||||
/// </summary>
|
||||
private async Task<PrintResult> DispatchAsync(
|
||||
string cafeId,
|
||||
string? deviceId,
|
||||
string? ip,
|
||||
int port,
|
||||
byte[] bytes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
var device = await _db.PrintDevices.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.Id == deviceId && d.CafeId == cafeId, ct);
|
||||
|
||||
if (device is not null && _agents.IsOnline(device.AgentId))
|
||||
{
|
||||
var outcome = await _agents.SendJobAsync(
|
||||
device.AgentId, new PrintJobRequest(device.SystemName, bytes), ct);
|
||||
if (outcome.Success)
|
||||
{
|
||||
_logger.LogInformation("Printed {Bytes} bytes via agent {Agent} → {Printer}",
|
||||
bytes.Length, device.AgentId, device.SystemName);
|
||||
return PrintResult.Ok();
|
||||
}
|
||||
_logger.LogWarning("Agent print failed ({Printer}): {Error}", device.SystemName, outcome.Error);
|
||||
// Only surface the failure if there's no IP to fall back to.
|
||||
if (string.IsNullOrWhiteSpace(ip))
|
||||
return PrintResult.Fail("AGENT_PRINT_FAILED", outcome.Error);
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(ip))
|
||||
{
|
||||
return PrintResult.Fail("AGENT_OFFLINE");
|
||||
}
|
||||
// Agent offline/missing but an IP is configured → fall through to TCP.
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ip))
|
||||
return await SendToPrinterAsync(ip!.Trim(), port, bytes, ct);
|
||||
|
||||
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
private async Task<(Branch branch, ReceiptPrintContext printCtx)?> BuildContextAsync(
|
||||
string cafeId,
|
||||
string orderId,
|
||||
@@ -243,7 +324,7 @@ public static class PrinterBackgroundJobs
|
||||
try
|
||||
{
|
||||
var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>();
|
||||
var result = await printer.PrintKitchenTicketAsync(cafeId, orderId, CancellationToken.None);
|
||||
var result = await printer.PrintKitchenTicketAsync(cafeId, orderId, null, CancellationToken.None);
|
||||
if (!result.Success)
|
||||
logger.LogWarning("Kitchen print failed for {OrderId}: {Code}", orderId, result.ErrorCode);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Meezi.API.Hubs;
|
||||
|
||||
namespace Meezi.API.Services.Printing;
|
||||
|
||||
public record PrintJobRequest(string PrinterSystemName, byte[] Payload);
|
||||
public record PrintJobOutcome(bool Success, string? Error);
|
||||
|
||||
/// <summary>A host the agent found on the café LAN responding on a probed port
|
||||
/// (a network printer on :9100, a card terminal on :8088, …).</summary>
|
||||
public record DiscoveredDevice(string Ip, int Port, string Kind);
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which print agents are currently connected (by SignalR connection) and
|
||||
/// dispatches print jobs to them, awaiting the agent's acknowledgement. In-memory:
|
||||
/// a dropped process simply means agents reconnect and re-register.
|
||||
/// </summary>
|
||||
public interface IPrintAgentRegistry
|
||||
{
|
||||
void Register(string connectionId, string agentId, string cafeId);
|
||||
void Unregister(string connectionId);
|
||||
(string AgentId, string CafeId)? Resolve(string connectionId);
|
||||
bool IsOnline(string agentId);
|
||||
IReadOnlySet<string> OnlineAgentIds();
|
||||
/// <summary>Online agents belonging to a café — used to pick a LAN bridge for a
|
||||
/// card-terminal payment or a network scan.</summary>
|
||||
IReadOnlySet<string> OnlineAgentIdsForCafe(string cafeId);
|
||||
Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default);
|
||||
void CompleteJob(string jobId, bool success, string? error);
|
||||
/// <summary>Relay a card-terminal payment through the agent on the café LAN; it
|
||||
/// POSTs the amount to the terminal at ip:port and acks the approval result.</summary>
|
||||
Task<PrintJobOutcome> SendPaymentAsync(
|
||||
string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default);
|
||||
/// <summary>Ask the agent to scan its LAN for hosts answering on the given ports.</summary>
|
||||
Task<IReadOnlyList<DiscoveredDevice>> ScanAsync(
|
||||
string agentId, string ports, CancellationToken ct = default);
|
||||
void CompleteScan(string requestId, IReadOnlyList<DiscoveredDevice> devices);
|
||||
}
|
||||
|
||||
public class PrintAgentRegistry : IPrintAgentRegistry
|
||||
{
|
||||
private readonly IHubContext<PrintAgentHub> _hub;
|
||||
private readonly ConcurrentDictionary<string, (string AgentId, string CafeId)> _byConnection = new();
|
||||
private readonly ConcurrentDictionary<string, string> _agentConnection = new(); // agentId -> connectionId
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<PrintJobOutcome>> _pending = new();
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<IReadOnlyList<DiscoveredDevice>>> _pendingScans = new();
|
||||
|
||||
public PrintAgentRegistry(IHubContext<PrintAgentHub> hub) => _hub = hub;
|
||||
|
||||
public void Register(string connectionId, string agentId, string cafeId)
|
||||
{
|
||||
_byConnection[connectionId] = (agentId, cafeId);
|
||||
_agentConnection[agentId] = connectionId;
|
||||
}
|
||||
|
||||
public void Unregister(string connectionId)
|
||||
{
|
||||
if (!_byConnection.TryRemove(connectionId, out var info)) return;
|
||||
// Only drop the agent→connection mapping if it still points at this socket
|
||||
// (a fast reconnect may already have replaced it with a newer one).
|
||||
if (_agentConnection.TryGetValue(info.AgentId, out var current) && current == connectionId)
|
||||
_agentConnection.TryRemove(info.AgentId, out _);
|
||||
}
|
||||
|
||||
public (string AgentId, string CafeId)? Resolve(string connectionId) =>
|
||||
_byConnection.TryGetValue(connectionId, out var info) ? info : null;
|
||||
|
||||
public bool IsOnline(string agentId) => _agentConnection.ContainsKey(agentId);
|
||||
|
||||
public IReadOnlySet<string> OnlineAgentIds() => _agentConnection.Keys.ToHashSet();
|
||||
|
||||
public IReadOnlySet<string> OnlineAgentIdsForCafe(string cafeId) =>
|
||||
_byConnection.Values
|
||||
.Where(v => v.CafeId == cafeId)
|
||||
.Select(v => v.AgentId)
|
||||
.ToHashSet();
|
||||
|
||||
public async Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default)
|
||||
{
|
||||
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
|
||||
return new PrintJobOutcome(false, "AGENT_OFFLINE");
|
||||
|
||||
var jobId = Guid.NewGuid().ToString("N");
|
||||
var tcs = new TaskCompletionSource<PrintJobOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[jobId] = tcs;
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Client(connectionId).SendAsync(
|
||||
"PrintJob", jobId, job.PrinterSystemName, Convert.ToBase64String(job.Payload), ct);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "TIMEOUT")));
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrintJobOutcome(false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(jobId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void CompleteJob(string jobId, bool success, string? error)
|
||||
{
|
||||
if (_pending.TryGetValue(jobId, out var tcs))
|
||||
tcs.TrySetResult(new PrintJobOutcome(success, error));
|
||||
}
|
||||
|
||||
public async Task<PrintJobOutcome> SendPaymentAsync(
|
||||
string agentId, string ip, int port, long amount, string orderId, CancellationToken ct = default)
|
||||
{
|
||||
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
|
||||
return new PrintJobOutcome(false, "AGENT_OFFLINE");
|
||||
|
||||
var requestId = Guid.NewGuid().ToString("N");
|
||||
var tcs = new TaskCompletionSource<PrintJobOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[requestId] = tcs;
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Client(connectionId).SendAsync(
|
||||
"PaymentRequest", requestId, ip, port, amount, orderId, ct);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
// Card payment waits on the customer at the terminal — give it the same
|
||||
// headroom the direct path uses.
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(95));
|
||||
using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "POS_DEVICE_TIMEOUT")));
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrintJobOutcome(false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(requestId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DiscoveredDevice>> ScanAsync(
|
||||
string agentId, string ports, CancellationToken ct = default)
|
||||
{
|
||||
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
|
||||
return [];
|
||||
|
||||
var requestId = Guid.NewGuid().ToString("N");
|
||||
var tcs = new TaskCompletionSource<IReadOnlyList<DiscoveredDevice>>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pendingScans[requestId] = tcs;
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Client(connectionId).SendAsync("ScanNetwork", requestId, ports, ct);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
using var reg = timeout.Token.Register(() => tcs.TrySetResult([]));
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingScans.TryRemove(requestId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void CompleteScan(string requestId, IReadOnlyList<DiscoveredDevice> devices)
|
||||
{
|
||||
if (_pendingScans.TryGetValue(requestId, out var tcs))
|
||||
tcs.TrySetResult(devices);
|
||||
}
|
||||
}
|
||||
@@ -228,11 +228,16 @@ public class ShiftService : IShiftService
|
||||
.Where(t => t.Type == CashTransactionType.OrderPayment && t.Method == PaymentMethod.Cash)
|
||||
.Sum(t => t.Amount);
|
||||
|
||||
// Payment corrections (اصلاح سند) refund cash back out of the drawer.
|
||||
var cashRefunds = transactions
|
||||
.Where(t => t.Type == CashTransactionType.Refund && t.Method == PaymentMethod.Cash)
|
||||
.Sum(t => t.Amount);
|
||||
|
||||
var withdrawals = transactions
|
||||
.Where(t => t.Type == CashTransactionType.Withdrawal)
|
||||
.Sum(t => t.Amount);
|
||||
|
||||
return openingCash + cashPayments - withdrawals;
|
||||
return openingCash + cashPayments - cashRefunds - withdrawals;
|
||||
}
|
||||
|
||||
private static ShiftDto ToDto(Shift s) => new(
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
@@ -11,14 +9,25 @@ namespace Meezi.API.Services;
|
||||
|
||||
public interface ISmsMarketingService
|
||||
{
|
||||
Task<SmsUsageDto> GetUsageAsync(string cafeId, PlanTier planTier, CancellationToken cancellationToken = default);
|
||||
Task<SmsUsageDto> GetUsageAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<SmsSettingsDto> GetSettingsAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<(bool Success, SmsSettingsDto? Data, string? ErrorCode, string? Message)> UpdateSettingsAsync(
|
||||
string cafeId,
|
||||
UpdateSmsSettingsRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<SmsBalanceDto> GetBalanceAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
SendSmsCampaignRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marketing SMS is bring-your-own-provider: each café configures its OWN
|
||||
/// Kavenegar API key + sender line and pays its provider directly. The platform
|
||||
/// neither sells SMS nor meters it against plan limits; the monthly counter is
|
||||
/// informational only. (Login OTPs still go through the platform account.)
|
||||
/// </summary>
|
||||
public class SmsMarketingService : ISmsMarketingService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
@@ -35,40 +44,111 @@ public class SmsMarketingService : ISmsMarketingService
|
||||
_redis = redis;
|
||||
}
|
||||
|
||||
public async Task<SmsUsageDto> GetUsageAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
CancellationToken cancellationToken = default)
|
||||
public async Task<SmsUsageDto> GetUsageAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var month = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
var used = await GetUsedCountAsync(cafeId, month);
|
||||
var limit = PlanLimits.MaxSmsPerMonth(planTier);
|
||||
return new SmsUsageDto(used, limit == int.MaxValue ? -1 : limit, month);
|
||||
// -1 = no platform limit; the café's own provider account is the only cap.
|
||||
return new SmsUsageDto(used, -1, month);
|
||||
}
|
||||
|
||||
public async Task<SmsSettingsDto> GetSettingsAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.AsNoTracking()
|
||||
.Where(c => c.Id == cafeId)
|
||||
.Select(c => new { c.SmsApiKey, c.SmsSenderNumber })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (cafe is null || string.IsNullOrWhiteSpace(cafe.SmsApiKey))
|
||||
return new SmsSettingsDto(false, null, cafe?.SmsSenderNumber);
|
||||
|
||||
return new SmsSettingsDto(true, MaskApiKey(cafe.SmsApiKey), cafe.SmsSenderNumber);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SmsSettingsDto? Data, string? ErrorCode, string? Message)> UpdateSettingsAsync(
|
||||
string cafeId,
|
||||
UpdateSmsSettingsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null)
|
||||
return (false, null, "NOT_FOUND", "Cafe not found.");
|
||||
|
||||
var apiKey = request.ApiKey?.Trim();
|
||||
var sender = request.SenderNumber?.Trim();
|
||||
|
||||
// Empty strings clear the configuration (turn SMS off for this café).
|
||||
if (string.IsNullOrEmpty(apiKey) && string.IsNullOrEmpty(sender))
|
||||
{
|
||||
cafe.SmsApiKey = null;
|
||||
cafe.SmsSenderNumber = null;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return (true, new SmsSettingsDto(false, null, null), null, null);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey) && string.IsNullOrEmpty(cafe.SmsApiKey))
|
||||
return (false, null, "VALIDATION_ERROR", "API key is required.");
|
||||
if (string.IsNullOrEmpty(sender))
|
||||
return (false, null, "VALIDATION_ERROR", "Sender number is required.");
|
||||
|
||||
// A new key was provided — verify it against the provider before saving so
|
||||
// the owner gets immediate feedback on a typo'd key.
|
||||
if (!string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
var info = await _smsService.GetAccountInfoAsync(apiKey, cancellationToken);
|
||||
if (info is null)
|
||||
return (false, null, "SMS_KEY_INVALID", "The API key was rejected by the SMS provider.");
|
||||
cafe.SmsApiKey = apiKey;
|
||||
}
|
||||
|
||||
cafe.SmsSenderNumber = sender;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (true, new SmsSettingsDto(true, MaskApiKey(cafe.SmsApiKey!), cafe.SmsSenderNumber), null, null);
|
||||
}
|
||||
|
||||
public async Task<SmsBalanceDto> GetBalanceAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKey = await _db.Cafes.AsNoTracking()
|
||||
.Where(c => c.Id == cafeId)
|
||||
.Select(c => c.SmsApiKey)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
return new SmsBalanceDto(0, "master", false);
|
||||
|
||||
var info = await _smsService.GetAccountInfoAsync(apiKey, cancellationToken);
|
||||
return info is not null
|
||||
? new SmsBalanceDto(info.RemainCredit, info.AccountType, true)
|
||||
: new SmsBalanceDto(0, "master", false);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, SmsCampaignResult? Data, string? ErrorCode, string? Message)> SendCampaignAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
SendSmsCampaignRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var maxSms = PlanLimits.MaxSmsPerMonth(planTier);
|
||||
if (maxSms == 0)
|
||||
return (false, null, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan.");
|
||||
var cafe = await _db.Cafes.AsNoTracking()
|
||||
.Where(c => c.Id == cafeId)
|
||||
.Select(c => new { c.SmsApiKey, c.SmsSenderNumber })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (cafe is null || string.IsNullOrWhiteSpace(cafe.SmsApiKey) || string.IsNullOrWhiteSpace(cafe.SmsSenderNumber))
|
||||
return (false, null, "SMS_NOT_CONFIGURED",
|
||||
"Configure your own SMS provider (API key + sender line) in the SMS settings first.");
|
||||
|
||||
var phones = await ResolvePhonesAsync(cafeId, request, cancellationToken);
|
||||
if (phones.Count == 0)
|
||||
return (false, null, "NOT_FOUND", "No recipients found.");
|
||||
|
||||
var month = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
var used = await GetUsedCountAsync(cafeId, month);
|
||||
if (maxSms != int.MaxValue && used + phones.Count > maxSms)
|
||||
return (false, null, "PLAN_LIMIT_REACHED", "Monthly SMS limit would be exceeded.");
|
||||
|
||||
var result = await _smsService.SendBulkAsync(phones, request.Message, cancellationToken);
|
||||
var result = await _smsService.SendBulkWithCredentialsAsync(
|
||||
cafe.SmsApiKey, cafe.SmsSenderNumber, phones, request.Message, cancellationToken);
|
||||
|
||||
if (result.SentCount > 0)
|
||||
{
|
||||
var month = DateTime.UtcNow.ToString("yyyy-MM");
|
||||
await IncrementUsageAsync(cafeId, month, result.SentCount);
|
||||
}
|
||||
|
||||
return (true, new SmsCampaignResult(result.SentCount, result.FailedCount), null, null);
|
||||
}
|
||||
@@ -94,6 +174,9 @@ public class SmsMarketingService : ISmsMarketingService
|
||||
return await query.Select(c => c.Phone).Distinct().ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string MaskApiKey(string apiKey) =>
|
||||
apiKey.Length <= 4 ? "****" : $"****{apiKey[^4..]}";
|
||||
|
||||
private async Task<int> GetUsedCountAsync(string cafeId, string month)
|
||||
{
|
||||
var redis = _redis.GetDatabase();
|
||||
|
||||
@@ -139,6 +139,22 @@ public class RecordPaymentsRequestValidator : AbstractValidator<RecordPaymentsRe
|
||||
}
|
||||
}
|
||||
|
||||
public class CorrectPaymentsRequestValidator : AbstractValidator<CorrectPaymentsRequest>
|
||||
{
|
||||
public CorrectPaymentsRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).NotEmpty().MinimumLength(3).MaximumLength(500);
|
||||
RuleFor(x => x)
|
||||
.Must(x => (x.VoidPaymentIds?.Count ?? 0) > 0 || (x.Replacements?.Count ?? 0) > 0)
|
||||
.WithMessage("At least one payment to void or one replacement is required.");
|
||||
RuleForEach(x => x.Replacements).ChildRules(p =>
|
||||
{
|
||||
p.RuleFor(x => x.Method).IsInEnum();
|
||||
p.RuleFor(x => x.Amount).GreaterThan(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class AppendOrderItemsRequestValidator : AbstractValidator<AppendOrderItemsRequest>
|
||||
{
|
||||
public AppendOrderItemsRequestValidator()
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
"MerchantId": "",
|
||||
"Sandbox": true
|
||||
},
|
||||
"FlatPay": {
|
||||
"BaseUrl": "https://pay.flatrender.ir",
|
||||
"ReturnUrl": "https://meezi.ir/payment/return",
|
||||
"ApiKey": "",
|
||||
"Secret": ""
|
||||
},
|
||||
"Billing": {
|
||||
"DashboardBaseUrl": "http://localhost:3101"
|
||||
},
|
||||
|
||||