Compare commits
41 Commits
149a4d88cd
..
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 |
+12
-2
@@ -23,8 +23,10 @@ JWT_KEY=change-me-64-char-random-string-use-openssl-rand-hex-32-output
|
|||||||
|
|
||||||
NEXT_PUBLIC_API_URL=http://171.22.25.73:5080
|
NEXT_PUBLIC_API_URL=http://171.22.25.73:5080
|
||||||
NEXT_PUBLIC_ADMIN_API_URL=http://171.22.25.73:5081
|
NEXT_PUBLIC_ADMIN_API_URL=http://171.22.25.73:5081
|
||||||
NEXT_PUBLIC_SITE_URL=http://171.22.25.73:3010
|
# Public site origin — MUST be the real domain in prod (used for canonical URLs,
|
||||||
NEXT_PUBLIC_KOJA_URL=http://171.22.25.73:3103
|
# sitemap, robots, OG tags). A wrong value here de-indexes the whole site in GSC.
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://meezi.ir
|
||||||
|
NEXT_PUBLIC_KOJA_URL=https://koja.meezi.ir
|
||||||
|
|
||||||
APP_QR_BASE_URL=http://171.22.25.73:3101
|
APP_QR_BASE_URL=http://171.22.25.73:3101
|
||||||
BILLING_DASHBOARD_URL=http://171.22.25.73:3101
|
BILLING_DASHBOARD_URL=http://171.22.25.73:3101
|
||||||
@@ -81,6 +83,14 @@ SEED_ADMIN_PASSWORD=change-me-strong-admin-password
|
|||||||
ZARINPAL_MERCHANT_ID=
|
ZARINPAL_MERCHANT_ID=
|
||||||
ZARINPAL_SANDBOX=false
|
ZARINPAL_SANDBOX=false
|
||||||
|
|
||||||
|
# ── Payment: FlatRender Pay (ZarinPal broker) ─────────────────────────────────
|
||||||
|
# Broker keys from the FlatRender dashboard. Webhook is registered at the broker as
|
||||||
|
# https://api.meezi.ir/api/payment/webhook. Keep the live secret OUT of git.
|
||||||
|
FLATPAY_API_KEY=
|
||||||
|
FLATPAY_SECRET=
|
||||||
|
FLATPAY_BASE_URL=https://pay.flatrender.ir
|
||||||
|
FLATPAY_RETURN_URL=https://meezi.ir/payment/return
|
||||||
|
|
||||||
# ── SMS: Kavenegar ────────────────────────────────────────────────────────────
|
# ── SMS: Kavenegar ────────────────────────────────────────────────────────────
|
||||||
# Empty = OTP is logged to API console (fine for dev, not for production)
|
# Empty = OTP is logged to API console (fine for dev, not for production)
|
||||||
KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D
|
KAVENEGAR_API_KEY=4C30786935496261332B41685870444E47657A5367453369374F6E2F43334672576B526F5A4B4B795665493D
|
||||||
|
|||||||
@@ -0,0 +1,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.
|
||||||
@@ -168,7 +168,7 @@ services:
|
|||||||
dockerfile: docker/website/Dockerfile
|
dockerfile: docker/website/Dockerfile
|
||||||
args:
|
args:
|
||||||
MEEZI_API_URL: http://api:8080
|
MEEZI_API_URL: http://api:8080
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}
|
||||||
container_name: meezi-website
|
container_name: meezi-website
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -178,7 +178,7 @@ services:
|
|||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
MEEZI_API_URL: http://api:8080
|
MEEZI_API_URL: http://api:8080
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}
|
||||||
ports:
|
ports:
|
||||||
- "${WEBSITE_PORT:-3010}:3000"
|
- "${WEBSITE_PORT:-3010}:3000"
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -94,6 +94,10 @@ services:
|
|||||||
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
||||||
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
||||||
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
||||||
|
FlatPay__ApiKey: "${FLATPAY_API_KEY:-}"
|
||||||
|
FlatPay__Secret: "${FLATPAY_SECRET:-}"
|
||||||
|
FlatPay__BaseUrl: "${FLATPAY_BASE_URL:-https://pay.flatrender.ir}"
|
||||||
|
FlatPay__ReturnUrl: "${FLATPAY_RETURN_URL:-https://meezi.ir/payment/return}"
|
||||||
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
|
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
|
||||||
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
|
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
|
||||||
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
|
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
|
||||||
@@ -139,7 +143,7 @@ services:
|
|||||||
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
||||||
MEEZI_API_URL: http://api:8080
|
MEEZI_API_URL: http://api:8080
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}
|
||||||
container_name: meezi-website
|
container_name: meezi-website
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -149,7 +153,7 @@ services:
|
|||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
MEEZI_API_URL: http://api:8080
|
MEEZI_API_URL: http://api:8080
|
||||||
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_SITE_URL:-http://localhost:3010}"
|
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_SITE_URL:-https://meezi.ir}"
|
||||||
ports:
|
ports:
|
||||||
- "${WEBSITE_PORT:-3010}:3000"
|
- "${WEBSITE_PORT:-3010}:3000"
|
||||||
|
|
||||||
@@ -163,7 +167,7 @@ services:
|
|||||||
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
NODE_IMAGE: ${NODE_IMAGE:-mirror.soroushasadi.com/node:20-alpine}
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
NPM_REGISTRY: ${NPM_REGISTRY:-https://mirror.soroushasadi.com/repository/npm-group/}
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5080}
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_KOJA_URL:-https://koja.meezi.ir}
|
||||||
container_name: meezi-koja
|
container_name: meezi-koja
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -173,7 +177,7 @@ services:
|
|||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://localhost:5080}"
|
NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://localhost:5080}"
|
||||||
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_KOJA_URL:-http://localhost:3103}"
|
NEXT_PUBLIC_SITE_URL: "${NEXT_PUBLIC_KOJA_URL:-https://koja.meezi.ir}"
|
||||||
ports:
|
ports:
|
||||||
- "${KOJA_PORT:-3103}:3000"
|
- "${KOJA_PORT:-3103}:3000"
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ FROM ${NODE_IMAGE} AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG MEEZI_API_URL=http://api:8080
|
ARG MEEZI_API_URL=http://api:8080
|
||||||
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3010
|
ARG NEXT_PUBLIC_SITE_URL=https://meezi.ir
|
||||||
|
|
||||||
ENV MEEZI_API_URL=$MEEZI_API_URL
|
ENV MEEZI_API_URL=$MEEZI_API_URL
|
||||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Audit;
|
using Meezi.API.Models.Audit;
|
||||||
using Meezi.Core.Authorization;
|
using Meezi.Core.Authorization;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -42,7 +43,7 @@ public class AuditController : CafeApiControllerBase
|
|||||||
[FromQuery] int pageSize = 50)
|
[FromQuery] int pageSize = 50)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsurePermission(tenant, Permission.ViewReports) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ViewAuditLog) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
if (page < 1) page = 1;
|
if (page < 1) page = 1;
|
||||||
if (pageSize < 1) pageSize = 50;
|
if (pageSize < 1) pageSize = 50;
|
||||||
@@ -67,25 +68,50 @@ public class AuditController : CafeApiControllerBase
|
|||||||
|
|
||||||
var total = await query.CountAsync(ct);
|
var total = await query.CountAsync(ct);
|
||||||
|
|
||||||
var items = await query
|
var rows = await query
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.Select(x => new AuditLogDto(
|
.Select(x => new
|
||||||
x.Id,
|
{
|
||||||
x.Category,
|
x.Id, x.Category, x.Action, x.EntityType, x.EntityId, x.BranchId,
|
||||||
x.Action,
|
x.ActorId, x.ActorName, x.ActorRole, x.Summary, x.DetailsJson, x.CreatedAt
|
||||||
x.EntityType,
|
})
|
||||||
x.EntityId,
|
|
||||||
x.BranchId,
|
|
||||||
x.ActorId,
|
|
||||||
x.ActorName,
|
|
||||||
x.ActorRole,
|
|
||||||
x.Summary,
|
|
||||||
x.DetailsJson,
|
|
||||||
x.CreatedAt))
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Resolve the actor's CURRENT full name + role from the employee record.
|
||||||
|
// This fixes historical rows (where ActorName was never stored) and keeps
|
||||||
|
// names current. IgnoreQueryFilters so we still name soft-deleted staff.
|
||||||
|
var actorIds = rows
|
||||||
|
.Where(r => !string.IsNullOrEmpty(r.ActorId))
|
||||||
|
.Select(r => r.ActorId!)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var employees = actorIds.Count == 0
|
||||||
|
? new Dictionary<string, (string Name, EmployeeRole Role)>()
|
||||||
|
: (await _db.Employees
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.CafeId == cafeId && actorIds.Contains(e.Id))
|
||||||
|
.Select(e => new { e.Id, e.Name, e.Role })
|
||||||
|
.ToListAsync(ct))
|
||||||
|
.ToDictionary(e => e.Id, e => (e.Name, e.Role));
|
||||||
|
|
||||||
|
var items = rows.Select(r =>
|
||||||
|
{
|
||||||
|
string? name = r.ActorName;
|
||||||
|
string? role = r.ActorRole;
|
||||||
|
if (!string.IsNullOrEmpty(r.ActorId) && employees.TryGetValue(r.ActorId, out var emp))
|
||||||
|
{
|
||||||
|
name = emp.Name; // prefer the live employee name
|
||||||
|
role ??= emp.Role.ToString();
|
||||||
|
}
|
||||||
|
return new AuditLogDto(
|
||||||
|
r.Id, r.Category, r.Action, r.EntityType, r.EntityId, r.BranchId,
|
||||||
|
r.ActorId, name, role, r.Summary, r.DetailsJson, r.CreatedAt);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
return Ok(new PagedApiResponse<AuditLogDto>(true, items, new PagedMeta(total, page, pageSize)));
|
return Ok(new PagedApiResponse<AuditLogDto>(true, items, new PagedMeta(total, page, pageSize)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Billing;
|
using Meezi.API.Models.Billing;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
public class BillingController : ControllerBase
|
public class BillingController : CafeApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IBillingService _billing;
|
private readonly IBillingService _billing;
|
||||||
private readonly IValidator<SubscribeRequest> _subscribeValidator;
|
private readonly IValidator<SubscribeRequest> _subscribeValidator;
|
||||||
@@ -27,13 +28,9 @@ public class BillingController : ControllerBase
|
|||||||
ITenantContext tenant,
|
ITenantContext tenant,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrEmpty(tenant.CafeId))
|
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
|
|
||||||
{
|
|
||||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
|
||||||
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
|
|
||||||
}
|
|
||||||
|
|
||||||
var validation = await _subscribeValidator.ValidateAsync(request, ct);
|
var validation = await _subscribeValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
@@ -108,11 +105,9 @@ public class BillingController : ControllerBase
|
|||||||
[HttpDelete("api/billing/queued/{paymentId}")]
|
[HttpDelete("api/billing/queued/{paymentId}")]
|
||||||
public async Task<IActionResult> CancelQueued(string paymentId, ITenantContext tenant, CancellationToken ct)
|
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))
|
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
|
|
||||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
|
||||||
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
|
|
||||||
|
|
||||||
var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
|
var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
|
||||||
if (!ok)
|
if (!ok)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Menu;
|
using Meezi.API.Models.Menu;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -43,7 +43,6 @@ public class BranchMenuController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{menuItemId}/override")]
|
[HttpPut("{menuItemId}/override")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> UpsertOverride(
|
public async Task<IActionResult> UpsertOverride(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -53,8 +52,7 @@ public class BranchMenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (!BranchMenuService.CanManageOverrides(tenant.Role))
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
return Forbid();
|
|
||||||
|
|
||||||
var validation = await _upsertValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _upsertValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
@@ -84,7 +82,6 @@ public class BranchMenuController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{menuItemId}/override")]
|
[HttpDelete("{menuItemId}/override")]
|
||||||
[Authorize(Roles = "Owner")]
|
|
||||||
public async Task<IActionResult> DeleteOverride(
|
public async Task<IActionResult> DeleteOverride(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -93,6 +90,7 @@ public class BranchMenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var deleted = await _branchMenu.DeleteOverrideAsync(
|
var deleted = await _branchMenu.DeleteOverrideAsync(
|
||||||
cafeId, branchId, menuItemId, cancellationToken);
|
cafeId, branchId, menuItemId, cancellationToken);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Printing;
|
using Meezi.API.Models.Printing;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Entities;
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
@@ -11,7 +11,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
[Route("api/cafes/{cafeId}/branches/{branchId}/print-settings")]
|
[Route("api/cafes/{cafeId}/branches/{branchId}/print-settings")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public class BranchPrintSettingsController : CafeApiControllerBase
|
public class BranchPrintSettingsController : CafeApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
@@ -54,6 +53,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var validation = await _validator.ValidateAsync(request, ct);
|
var validation = await _validator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
@@ -91,6 +91,14 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
|||||||
: request.PosDeviceIp.Trim();
|
: request.PosDeviceIp.Trim();
|
||||||
if (request.PosDevicePort.HasValue)
|
if (request.PosDevicePort.HasValue)
|
||||||
branch.PosDevicePort = request.PosDevicePort.Value;
|
branch.PosDevicePort = request.PosDevicePort.Value;
|
||||||
|
if (request.ReceiptPrintDeviceId is not null)
|
||||||
|
branch.ReceiptPrintDeviceId = string.IsNullOrWhiteSpace(request.ReceiptPrintDeviceId)
|
||||||
|
? null
|
||||||
|
: request.ReceiptPrintDeviceId;
|
||||||
|
if (request.KitchenPrintDeviceId is not null)
|
||||||
|
branch.KitchenPrintDeviceId = string.IsNullOrWhiteSpace(request.KitchenPrintDeviceId)
|
||||||
|
? null
|
||||||
|
: request.KitchenPrintDeviceId;
|
||||||
|
|
||||||
branch.UpdatedAt = DateTime.UtcNow;
|
branch.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
@@ -110,5 +118,7 @@ public class BranchPrintSettingsController : CafeApiControllerBase
|
|||||||
b.ReceiptFooter,
|
b.ReceiptFooter,
|
||||||
b.WifiPassword,
|
b.WifiPassword,
|
||||||
b.PosDeviceIp,
|
b.PosDeviceIp,
|
||||||
b.PosDevicePort);
|
b.PosDevicePort,
|
||||||
|
b.ReceiptPrintDeviceId,
|
||||||
|
b.KitchenPrintDeviceId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Tables;
|
using Meezi.API.Models.Tables;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -68,7 +68,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> CreateTable(
|
public async Task<IActionResult> CreateTable(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -77,6 +76,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -88,7 +88,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id}")]
|
[HttpPatch("{id}")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> PatchTable(
|
public async Task<IActionResult> PatchTable(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -98,6 +97,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -109,7 +109,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> DeleteTable(
|
public async Task<IActionResult> DeleteTable(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -118,6 +117,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -135,6 +135,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -180,7 +181,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("sections")]
|
[HttpPost("sections")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> CreateSection(
|
public async Task<IActionResult> CreateSection(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -189,6 +189,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -200,7 +201,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("sections/{sectionId}")]
|
[HttpPatch("sections/{sectionId}")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> PatchSection(
|
public async Task<IActionResult> PatchSection(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -210,6 +210,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
@@ -221,7 +222,6 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("sections/{sectionId}")]
|
[HttpDelete("sections/{sectionId}")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> DeleteSection(
|
public async Task<IActionResult> DeleteSection(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string branchId,
|
string branchId,
|
||||||
@@ -230,6 +230,7 @@ public class BranchTablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
if (!await _tables.CanAccessBranchAsync(cafeId, branchId, tenant.UserId, tenant.Role, ct))
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Branches;
|
using Meezi.API.Models.Branches;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Constants;
|
using Meezi.Core.Constants;
|
||||||
using Meezi.Core.Entities;
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
@@ -96,7 +97,7 @@ public class BranchesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
if (EnsurePermission(tenant, Permission.CreateBranch) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
@@ -169,7 +170,7 @@ public class BranchesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
if (EnsurePermission(tenant, Permission.EditBranch) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var validation = await _patchValidator.ValidateAsync(request, ct);
|
var validation = await _patchValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
@@ -222,7 +223,7 @@ public class BranchesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
if (EnsurePermission(tenant, Permission.DeleteBranch) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var (ok, code, message) = await _lifecycle.ScheduleDeletionAsync(cafeId, branchId, ct);
|
var (ok, code, message) = await _lifecycle.ScheduleDeletionAsync(cafeId, branchId, ct);
|
||||||
if (!ok)
|
if (!ok)
|
||||||
@@ -257,7 +258,7 @@ public class BranchesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
if (EnsurePermission(tenant, Permission.EditBranch) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var (ok, code, message) = await _lifecycle.RestoreAsync(cafeId, branchId, ct);
|
var (ok, code, message) = await _lifecycle.RestoreAsync(cafeId, branchId, ct);
|
||||||
if (!ok)
|
if (!ok)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Discover;
|
using Meezi.API.Models.Discover;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
@@ -45,6 +46,7 @@ public class CafeDiscoverProfileController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied)
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied)
|
||||||
return denied;
|
return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "discover_profile", ct))
|
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "discover_profile", ct))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Discover;
|
using Meezi.Core.Discover;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
@@ -71,6 +72,7 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
||||||
if (cafe is null)
|
if (cafe is null)
|
||||||
@@ -121,6 +123,7 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
if (photo is null || photo.Length == 0)
|
if (photo is null || photo.Length == 0)
|
||||||
return BadRequest(Fail("NO_FILE", "No photo provided."));
|
return BadRequest(Fail("NO_FILE", "No photo provided."));
|
||||||
@@ -155,6 +158,7 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageDiscoverProfile) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
return BadRequest(Fail("NO_URL", "Provide ?url= of the photo to remove."));
|
return BadRequest(Fail("NO_URL", "Provide ?url= of the photo to remove."));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Services.Platform;
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
@@ -48,6 +49,7 @@ public class CafeReviewsController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageReviews) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
// Replying to reviews is a paid feature (Starter+).
|
// Replying to reviews is a paid feature (Starter+).
|
||||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
@@ -76,6 +78,7 @@ public class CafeReviewsController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageReviews) is { } permDenied) return permDenied;
|
||||||
var data = await _reviews.SetHiddenAsync(cafeId, reviewId, request.IsHidden, ct);
|
var data = await _reviews.SetHiddenAsync(cafeId, reviewId, request.IsHidden, ct);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<CafeReviewDto>(true, data));
|
return Ok(new ApiResponse<CafeReviewDto>(true, data));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Cafes;
|
using Meezi.API.Models.Cafes;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Core.Utilities;
|
using Meezi.Core.Utilities;
|
||||||
@@ -47,6 +48,7 @@ public class CafeSettingsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var validation = await _validator.ValidateAsync(request, ct);
|
var validation = await _validator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid)
|
if (!validation.IsValid)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Crm;
|
using Meezi.API.Models.Crm;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ public class CouponsController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateCoupon) is { } permDenied) return permDenied;
|
||||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ public class CouponsController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditCoupon) is { } permDenied) return permDenied;
|
||||||
var data = await _couponService.UpdateAsync(cafeId, id, request, cancellationToken);
|
var data = await _couponService.UpdateAsync(cafeId, id, request, cancellationToken);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<CouponDto>(true, data));
|
return Ok(new ApiResponse<CouponDto>(true, data));
|
||||||
@@ -95,6 +98,7 @@ public class CouponsController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteCoupon) is { } permDenied) return permDenied;
|
||||||
var deleted = await _couponService.DeleteAsync(cafeId, id, cancellationToken);
|
var deleted = await _couponService.DeleteAsync(cafeId, id, cancellationToken);
|
||||||
if (!deleted) return NotFoundError();
|
if (!deleted) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id }));
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public class CustomRolesController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
|
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var roles = await _db.CustomRoles
|
var roles = await _db.CustomRoles
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -57,7 +57,7 @@ public class CustomRolesController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> Get(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
|
public async Task<IActionResult> Get(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var r = await _db.CustomRoles.AsNoTracking()
|
var r = await _db.CustomRoles.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(x => x.Id == id && x.CafeId == cafeId, ct);
|
.FirstOrDefaultAsync(x => x.Id == id && x.CafeId == cafeId, ct);
|
||||||
@@ -80,7 +80,7 @@ public class CustomRolesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var name = request.Name?.Trim() ?? string.Empty;
|
var name = request.Name?.Trim() ?? string.Empty;
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
@@ -113,7 +113,7 @@ public class CustomRolesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var role = await _db.CustomRoles
|
var role = await _db.CustomRoles
|
||||||
.FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct);
|
.FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct);
|
||||||
@@ -152,7 +152,7 @@ public class CustomRolesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var role = await _db.CustomRoles
|
var role = await _db.CustomRoles
|
||||||
.FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct);
|
.FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct);
|
||||||
@@ -180,7 +180,7 @@ public class CustomRolesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageRoles) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var employee = await _db.Employees
|
var employee = await _db.Employees
|
||||||
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Crm;
|
using Meezi.API.Models.Crm;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ public class CustomersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateCustomer) is { } permDenied) return permDenied;
|
||||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -77,6 +79,7 @@ public class CustomersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditCustomer) is { } permDenied) return permDenied;
|
||||||
var validation = await _updateValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _updateValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -99,6 +102,7 @@ public class CustomersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteCustomer) is { } permDenied) return permDenied;
|
||||||
var deleted = await _customerService.DeleteAsync(cafeId, id, cancellationToken);
|
var deleted = await _customerService.DeleteAsync(cafeId, id, cancellationToken);
|
||||||
if (!deleted) return NotFoundError();
|
if (!deleted) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id }));
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Services.Delivery;
|
using Meezi.API.Services.Delivery;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ public class DeliveryReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var utcTo = to ?? DateTime.UtcNow;
|
var utcTo = to ?? DateTime.UtcNow;
|
||||||
var utcFrom = from ?? utcTo.AddDays(-30);
|
var utcFrom = from ?? utcTo.AddDays(-30);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Expenses;
|
using Meezi.API.Models.Expenses;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -30,14 +30,11 @@ public class ExpensesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateExpense) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrEmpty(tenant.UserId))
|
if (string.IsNullOrEmpty(tenant.UserId))
|
||||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||||
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
||||||
|
|
||||||
if (!CanLogExpense(tenant.Role))
|
|
||||||
return StatusCode(StatusCodes.Status403Forbidden,
|
|
||||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "You cannot log expenses.")));
|
|
||||||
|
|
||||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -57,6 +54,7 @@ public class ExpensesController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewExpenses) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(branchId))
|
if (string.IsNullOrWhiteSpace(branchId))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
@@ -85,10 +83,7 @@ public class ExpensesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteExpense) is { } permDenied) return permDenied;
|
||||||
if (!CanDeleteExpense(tenant.Role))
|
|
||||||
return StatusCode(StatusCodes.Status403Forbidden,
|
|
||||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Only managers can delete expenses.")));
|
|
||||||
|
|
||||||
var result = await _expenses.DeleteExpenseAsync(cafeId, id, ct);
|
var result = await _expenses.DeleteExpenseAsync(cafeId, id, ct);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
@@ -104,12 +99,6 @@ public class ExpensesController : CafeApiControllerBase
|
|||||||
return Ok(new ApiResponse<object>(true, null));
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool CanLogExpense(EmployeeRole? role) =>
|
|
||||||
role is EmployeeRole.Owner or EmployeeRole.Manager or EmployeeRole.Cashier;
|
|
||||||
|
|
||||||
private static bool CanDeleteExpense(EmployeeRole? role) =>
|
|
||||||
role is EmployeeRole.Owner or EmployeeRole.Manager;
|
|
||||||
|
|
||||||
private IActionResult ExpenseResult(ExpenseServiceResult<ExpenseDto> result, int successStatus = StatusCodes.Status200OK)
|
private IActionResult ExpenseResult(ExpenseServiceResult<ExpenseDto> result, int successStatus = StatusCodes.Status200OK)
|
||||||
{
|
{
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Hr;
|
using Meezi.API.Models.Hr;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Entities;
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
@@ -43,6 +44,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewStaff) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.GetEmployeesAsync(cafeId, branchId, ct);
|
var data = await _hr.GetEmployeesAsync(cafeId, branchId, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -57,7 +59,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.CreateStaff) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
IActionResult Invalid(string message, string field) =>
|
IActionResult Invalid(string message, string field) =>
|
||||||
BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", message, field)));
|
BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", message, field)));
|
||||||
@@ -183,6 +185,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewAttendance) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.GetAttendanceAsync(cafeId, employeeId, from, to, ct);
|
var data = await _hr.GetAttendanceAsync(cafeId, employeeId, from, to, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<AttendanceDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<AttendanceDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -191,6 +194,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> GetShifts(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
public async Task<IActionResult> GetShifts(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewSchedules) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.GetShiftsAsync(cafeId, employeeId, ct);
|
var data = await _hr.GetShiftsAsync(cafeId, employeeId, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -204,7 +208,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageSchedules) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.UpsertShiftsAsync(cafeId, employeeId, request, ct);
|
var data = await _hr.UpsertShiftsAsync(cafeId, employeeId, request, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<ShiftDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -217,6 +221,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ReviewLeave) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.GetLeaveRequestsAsync(cafeId, status, ct);
|
var data = await _hr.GetLeaveRequestsAsync(cafeId, status, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<LeaveRequestDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<LeaveRequestDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -248,7 +253,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ReviewLeave) is { } forbidden) return forbidden;
|
||||||
var validation = await _reviewValidator.ValidateAsync(request, ct);
|
var validation = await _reviewValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -265,6 +270,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewSalaries) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.GetSalariesAsync(cafeId, monthYear, ct);
|
var data = await _hr.GetSalariesAsync(cafeId, monthYear, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<EmployeeSalaryDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<EmployeeSalaryDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -277,7 +283,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageSalaries) is { } forbidden) return forbidden;
|
||||||
var validation = await _salaryValidator.ValidateAsync(request, ct);
|
var validation = await _salaryValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -290,7 +296,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> MarkPaid(string cafeId, string salaryId, ITenantContext tenant, CancellationToken ct)
|
public async Task<IActionResult> MarkPaid(string cafeId, string salaryId, ITenantContext tenant, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageSalaries) is { } forbidden) return forbidden;
|
||||||
var data = await _hr.MarkSalaryPaidAsync(cafeId, salaryId, ct);
|
var data = await _hr.MarkSalaryPaidAsync(cafeId, salaryId, ct);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
|
||||||
@@ -306,7 +312,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageStaffCredentials) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var username = request.Username.Trim().ToLowerInvariant();
|
var username = request.Username.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
@@ -344,7 +350,7 @@ public class HrController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageStaffCredentials) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var employee = await _db.Employees
|
var employee = await _db.Employees
|
||||||
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ public class InventoryController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateInventory) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required.")));
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Name is required.")));
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ public class InventoryController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditInventory) is { } permDenied) return permDenied;
|
||||||
var updated = await _inventory.UpdateAsync(cafeId, ingredientId, request, ct);
|
var updated = await _inventory.UpdateAsync(cafeId, ingredientId, request, ct);
|
||||||
if (updated is null) return NotFoundError();
|
if (updated is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, updated));
|
return Ok(new ApiResponse<object>(true, updated));
|
||||||
@@ -69,6 +72,7 @@ public class InventoryController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteInventory) is { } permDenied) return permDenied;
|
||||||
var deleted = await _inventory.DeleteAsync(cafeId, ingredientId, ct);
|
var deleted = await _inventory.DeleteAsync(cafeId, ingredientId, ct);
|
||||||
if (!deleted) return NotFoundError();
|
if (!deleted) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id = ingredientId }));
|
return Ok(new ApiResponse<object>(true, new { id = ingredientId }));
|
||||||
@@ -83,6 +87,7 @@ public class InventoryController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditInventory) is { } permDenied) return permDenied;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var updated = await _inventory.AdjustAsync(cafeId, ingredientId, request, tenant.UserId, ct);
|
var updated = await _inventory.AdjustAsync(cafeId, ingredientId, request, tenant.UserId, ct);
|
||||||
@@ -146,6 +151,7 @@ public class InventoryController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditInventory) is { } permDenied) return permDenied;
|
||||||
var recipe = await _inventory.SetRecipeAsync(cafeId, menuItemId, request, ct);
|
var recipe = await _inventory.SetRecipeAsync(cafeId, menuItemId, request, ct);
|
||||||
if (recipe is null) return NotFoundError("Menu item not found.");
|
if (recipe is null) return NotFoundError("Menu item not found.");
|
||||||
return Ok(new ApiResponse<object>(true, recipe));
|
return Ok(new ApiResponse<object>(true, recipe));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Kitchen;
|
using Meezi.API.Models.Kitchen;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ public class KitchenStationsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
|
||||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ public class KitchenStationsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
|
||||||
var validation = await _updateValidator.ValidateAsync(request, ct);
|
var validation = await _updateValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -71,6 +74,7 @@ public class KitchenStationsController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> Delete(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
|
public async Task<IActionResult> Delete(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageKitchenStations) is { } permDenied) return permDenied;
|
||||||
var ok = await _stations.DeleteAsync(cafeId, id, ct);
|
var ok = await _stations.DeleteAsync(cafeId, id, ct);
|
||||||
if (!ok) return NotFoundError();
|
if (!ok) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id }));
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
@@ -20,13 +21,21 @@ public class MediaController : CafeApiControllerBase
|
|||||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadMenuImage(
|
public Task<IActionResult> UploadMenuImage(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveMenuImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return Upload(cafeId, file, tenant, _media.SaveMenuImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("menu-video")]
|
[HttpPost("menu-video")]
|
||||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadMenuVideo(
|
public Task<IActionResult> UploadMenuVideo(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveMenuVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return Upload(cafeId, file, tenant, _media.SaveMenuVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("menu-model3d")]
|
[HttpPost("menu-model3d")]
|
||||||
[RequestSizeLimit(8 * 1024 * 1024)]
|
[RequestSizeLimit(8 * 1024 * 1024)]
|
||||||
@@ -38,6 +47,7 @@ public class MediaController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
var planTier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "menu_3d", cancellationToken))
|
if (!await catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "menu_3d", cancellationToken))
|
||||||
{
|
{
|
||||||
@@ -63,25 +73,41 @@ public class MediaController : CafeApiControllerBase
|
|||||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadTableImage(
|
public Task<IActionResult> UploadTableImage(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveTableImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return Upload(cafeId, file, tenant, _media.SaveTableImageAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("table-video")]
|
[HttpPost("table-video")]
|
||||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadTableVideo(
|
public Task<IActionResult> UploadTableVideo(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveTableVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return Upload(cafeId, file, tenant, _media.SaveTableVideoAsync, "INVALID_FILE", "Use MP4/WebM/MOV up to 25MB.", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("cafe-logo")]
|
[HttpPost("cafe-logo")]
|
||||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadCafeLogo(
|
public Task<IActionResult> UploadCafeLogo(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveCafeLogoAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return Upload(cafeId, file, tenant, _media.SaveCafeLogoAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("cafe-cover")]
|
[HttpPost("cafe-cover")]
|
||||||
[RequestSizeLimit(5 * 1024 * 1024)]
|
[RequestSizeLimit(5 * 1024 * 1024)]
|
||||||
public Task<IActionResult> UploadCafeCover(
|
public Task<IActionResult> UploadCafeCover(
|
||||||
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return Task.FromResult(denied);
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return Task.FromResult(permDenied);
|
||||||
|
return Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Media library for this café — previously uploaded files so the UI can
|
/// <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>
|
/// 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 Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Menu;
|
using Meezi.API.Models.Menu;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Constants;
|
using Meezi.Core.Constants;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
@@ -59,6 +60,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateMenuItem) is { } permDenied) return permDenied;
|
||||||
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -86,6 +88,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
var data = await _menuService.UpdateCategoryAsync(cafeId, id, request, cancellationToken);
|
var data = await _menuService.UpdateCategoryAsync(cafeId, id, request, cancellationToken);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
|
||||||
@@ -95,6 +98,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> DeleteCategory(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
public async Task<IActionResult> DeleteCategory(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteMenuItem) is { } permDenied) return permDenied;
|
||||||
var deleted = await _menuService.DeleteCategoryAsync(cafeId, id, cancellationToken);
|
var deleted = await _menuService.DeleteCategoryAsync(cafeId, id, cancellationToken);
|
||||||
if (!deleted) return NotFoundError();
|
if (!deleted) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id }));
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
@@ -120,6 +124,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateMenuItem) is { } permDenied) return permDenied;
|
||||||
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -148,6 +153,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
var data = await _menuService.UpdateItemAsync(cafeId, id, request, cancellationToken);
|
var data = await _menuService.UpdateItemAsync(cafeId, id, request, cancellationToken);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||||
@@ -162,6 +168,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
var data = await _menuService.SetAvailabilityAsync(cafeId, id, request.IsAvailable, cancellationToken);
|
var data = await _menuService.SetAvailabilityAsync(cafeId, id, request.IsAvailable, cancellationToken);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||||
@@ -171,6 +178,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
public async Task<IActionResult> DeleteItem(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
public async Task<IActionResult> DeleteItem(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteMenuItem) is { } permDenied) return permDenied;
|
||||||
var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
|
var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
|
||||||
if (!deleted) return NotFoundError();
|
if (!deleted) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id }));
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
@@ -193,6 +201,7 @@ public class MenuController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditMenuItem) is { } permDenied) return permDenied;
|
||||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||||
var (data, code, message) = await _menuAi3d.GenerateFromItemImageAsync(cafeId, id, tier, cancellationToken);
|
var (data, code, message) = await _menuAi3d.GenerateFromItemImageAsync(cafeId, id, tier, cancellationToken);
|
||||||
if (code is not null)
|
if (code is not null)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Orders;
|
using Meezi.API.Models.Orders;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
@@ -120,6 +119,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ProcessOrders) is { } permDenied) return permDenied;
|
||||||
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _createValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -139,6 +139,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
|
||||||
var validation = await _appendValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _appendValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -150,7 +151,6 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id}/items/{itemId}/void")]
|
[HttpPatch("{id}/items/{itemId}/void")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> VoidOrderItem(
|
public async Task<IActionResult> VoidOrderItem(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string id,
|
string id,
|
||||||
@@ -159,6 +159,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.VoidOrder) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrEmpty(tenant.UserId))
|
if (string.IsNullOrEmpty(tenant.UserId))
|
||||||
return StatusCode(StatusCodes.Status403Forbidden,
|
return StatusCode(StatusCodes.Status403Forbidden,
|
||||||
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User context required.")));
|
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "User context required.")));
|
||||||
@@ -181,7 +182,6 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id}/transfer")]
|
[HttpPost("{id}/transfer")]
|
||||||
[Authorize(Roles = "Manager,Owner,Waiter")]
|
|
||||||
public async Task<IActionResult> TransferTable(
|
public async Task<IActionResult> TransferTable(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string id,
|
string id,
|
||||||
@@ -190,6 +190,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var result = await _orderService.TransferTableAsync(cafeId, id, request.TargetTableId, cancellationToken);
|
var result = await _orderService.TransferTableAsync(cafeId, id, request.TargetTableId, cancellationToken);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
@@ -207,6 +208,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditOrder) is { } permDenied) return permDenied;
|
||||||
var validation = await _sessionValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _sessionValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -226,6 +228,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.UpdateOrderStatus) is { } permDenied) return permDenied;
|
||||||
var validation = await _statusValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _statusValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -243,7 +246,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsurePermission(tenant, Permission.ProcessOrders) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.VoidOrder) is { } forbidden) return forbidden;
|
||||||
|
|
||||||
var result = await _orderService.CancelOrderAsync(
|
var result = await _orderService.CancelOrderAsync(
|
||||||
cafeId, id, request.Reason, tenant.UserId, cancellationToken);
|
cafeId, id, request.Reason, tenant.UserId, cancellationToken);
|
||||||
@@ -279,6 +282,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.HandlePayments) is { } permDenied) return permDenied;
|
||||||
var validation = await _paymentsValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _paymentsValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -319,7 +323,7 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageFinancials) is { } forbidden) return forbidden;
|
||||||
var validation = await _correctionValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _correctionValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -372,6 +376,8 @@ public class OrdersController : CafeApiControllerBase
|
|||||||
false, null, new ApiError(code, "Order is already cancelled.", field))),
|
false, null, new ApiError(code, "Order is already cancelled.", field))),
|
||||||
"ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse<object>(
|
"ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse<object>(
|
||||||
false, null, new ApiError(code, "Refund the recorded payments before cancelling this order.", field))),
|
false, null, new ApiError(code, "Refund the recorded payments before cancelling this order.", field))),
|
||||||
|
"ORDER_IN_PREPARATION" => Conflict(new ApiResponse<object>(
|
||||||
|
false, null, new ApiError(code, "This order has already been sent to the kitchen and cannot be cancelled.", field))),
|
||||||
"ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>(
|
"ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>(
|
||||||
false, null, new ApiError(code, "Line item not found.", field))),
|
false, null, new ApiError(code, "Line item not found.", field))),
|
||||||
"ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>(
|
"ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>(
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Meezi.API.Models.Payments;
|
||||||
|
using Meezi.API.Services;
|
||||||
|
using Meezi.API.Services.Payments;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Shared;
|
||||||
|
|
||||||
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>FlatRender Pay (ZarinPal broker) checkout + webhook.</summary>
|
||||||
|
[ApiController]
|
||||||
|
public class PaymentController : CafeApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IBillingService _billing;
|
||||||
|
private readonly IFlatPayService _flatPay;
|
||||||
|
private readonly ILogger<PaymentController> _logger;
|
||||||
|
|
||||||
|
public PaymentController(IBillingService billing, IFlatPayService flatPay, ILogger<PaymentController> logger)
|
||||||
|
{
|
||||||
|
_billing = billing;
|
||||||
|
_flatPay = flatPay;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Start a FlatPay checkout for a plan bundle; returns the URL to redirect the buyer to.</summary>
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("api/payment/request")]
|
||||||
|
public async Task<IActionResult> CreatePayment(
|
||||||
|
[FromBody] PaymentRequestDto request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageBilling) is { } permDenied) return permDenied;
|
||||||
|
if (string.IsNullOrEmpty(tenant.CafeId)) return Unauthorized();
|
||||||
|
|
||||||
|
if (request?.ProductId is null || !TryParseProduct(request.ProductId, out var tier, out var months))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("INVALID_PRODUCT", "productId must be a \"Tier:Months\" bundle, e.g. \"Pro:12\".")));
|
||||||
|
|
||||||
|
var (paymentId, amountToman, code, message) =
|
||||||
|
await _billing.CreateFlatPayOrderAsync(tenant.CafeId, tier, months, ct);
|
||||||
|
if (paymentId is null)
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||||
|
|
||||||
|
var description = $"میزی — اشتراک {tier} ({months} ماه)";
|
||||||
|
var url = await _flatPay.RequestAsync(
|
||||||
|
tenant.CafeId, request.ProductId, (long)amountToman, description, paymentId, ct);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(url))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("PAYMENT_FAILED", "Could not start the payment.")));
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<PaymentRequestResponse>(true, new PaymentRequestResponse(url, paymentId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Broker → us. Security is the HMAC signature (no user auth). Always 200 after a valid
|
||||||
|
/// signature so the broker doesn't retry a job we've accepted.</summary>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("api/payment/webhook")]
|
||||||
|
public async Task<IActionResult> Webhook(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await Request.Body.CopyToAsync(ms, ct);
|
||||||
|
var raw = ms.ToArray();
|
||||||
|
|
||||||
|
var signature = Request.Headers["X-FlatPay-Signature"].ToString();
|
||||||
|
if (!_flatPay.VerifyWebhook(raw, signature))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(raw);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var status = GetString(root, "status");
|
||||||
|
var brokerId = GetString(root, "id") ?? GetString(root, "payment_id");
|
||||||
|
|
||||||
|
if (string.Equals(status, "Paid", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.IsNullOrEmpty(brokerId)
|
||||||
|
&& _flatPay.TryMarkProcessed(brokerId))
|
||||||
|
{
|
||||||
|
var meta = root.TryGetProperty("metadata", out var m) && m.ValueKind == JsonValueKind.Object
|
||||||
|
? m
|
||||||
|
: default;
|
||||||
|
var paymentId = GetString(meta, "payment_id");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(paymentId))
|
||||||
|
await _billing.CompleteFlatPayAsync(paymentId, brokerId, ct);
|
||||||
|
else
|
||||||
|
_logger.LogWarning("FlatPay webhook Paid but missing metadata.payment_id (broker id {Id})", brokerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "FlatPay webhook processing error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parse a "Tier:Months" product id, e.g. "Pro:12" → (PlanTier.Pro, 12).</summary>
|
||||||
|
private static bool TryParseProduct(string productId, out PlanTier tier, out int months)
|
||||||
|
{
|
||||||
|
tier = default;
|
||||||
|
months = 0;
|
||||||
|
var parts = productId.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length != 2) return false;
|
||||||
|
return Enum.TryParse(parts[0], ignoreCase: true, out tier)
|
||||||
|
&& tier != PlanTier.Free
|
||||||
|
&& int.TryParse(parts[1], out months)
|
||||||
|
&& months > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement el, string name)
|
||||||
|
{
|
||||||
|
if (el.ValueKind != JsonValueKind.Object || !el.TryGetProperty(name, out var v))
|
||||||
|
return null;
|
||||||
|
return v.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => v.GetString(),
|
||||||
|
JsonValueKind.Number => v.ToString(),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Printing;
|
using Meezi.API.Models.Printing;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
[Route("api/cafes/{cafeId}/branches/{branchId}/pos-device")]
|
[Route("api/cafes/{cafeId}/branches/{branchId}/pos-device")]
|
||||||
[Authorize(Roles = "Cashier,Manager,Owner")]
|
|
||||||
public class PosDeviceController : CafeApiControllerBase
|
public class PosDeviceController : CafeApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly IPosDeviceService _posDevice;
|
private readonly IPosDeviceService _posDevice;
|
||||||
@@ -30,6 +29,7 @@ public class PosDeviceController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.HandlePayments) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var validation = await _validator.ValidateAsync(request, ct);
|
var validation = await _validator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Meezi.API.Hubs;
|
||||||
|
using Meezi.API.Models.Printing;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Meezi.Shared;
|
||||||
|
|
||||||
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anonymous endpoint the print-agent installer calls to redeem a pairing code for
|
||||||
|
/// a long-lived token. The token is returned exactly once; only its hash is stored.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("api/print-agent")]
|
||||||
|
public class PrintAgentPairingController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public PrintAgentPairingController(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
[HttpPost("claim")]
|
||||||
|
public async Task<IActionResult> Claim([FromBody] ClaimAgentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Code))
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError("CODE_REQUIRED", "Pairing code is required.")));
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var agent = await _db.PrintAgents
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(a =>
|
||||||
|
a.PairingCode == request.Code &&
|
||||||
|
a.TokenHash == null &&
|
||||||
|
!a.Revoked &&
|
||||||
|
a.DeletedAt == null &&
|
||||||
|
a.PairingCodeExpiresAt > now, ct);
|
||||||
|
|
||||||
|
if (agent is null)
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("INVALID_OR_EXPIRED_CODE", "This pairing code is invalid or has expired.")));
|
||||||
|
|
||||||
|
var token = NewToken();
|
||||||
|
agent.TokenHash = PrintAgentHub.HashToken(token);
|
||||||
|
agent.PairingCode = null;
|
||||||
|
agent.PairingCodeExpiresAt = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name)) agent.Name = request.Name!.Trim();
|
||||||
|
else if (!string.IsNullOrWhiteSpace(request.MachineName)) agent.Name = request.MachineName!.Trim();
|
||||||
|
agent.LastSeenAt = now;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<ClaimAgentResponse>(
|
||||||
|
true, new ClaimAgentResponse(agent.Id, token, agent.CafeId, agent.Name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NewToken()
|
||||||
|
{
|
||||||
|
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||||
|
return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Meezi.API.Hubs;
|
||||||
|
using Meezi.API.Models.Printing;
|
||||||
|
using Meezi.API.Services.Printing;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
|
using Meezi.Core.Entities;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Meezi.Shared;
|
||||||
|
|
||||||
|
namespace Meezi.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Manage the local print agents paired to a café (cloud → LAN bridge).</summary>
|
||||||
|
[Route("api/cafes/{cafeId}/print-agents")]
|
||||||
|
public class PrintAgentsController : CafeApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IPrintAgentRegistry _registry;
|
||||||
|
private readonly IPrinterService _printer;
|
||||||
|
|
||||||
|
public PrintAgentsController(AppDbContext db, IPrintAgentRegistry registry, IPrinterService printer)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_registry = registry;
|
||||||
|
_printer = printer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> List(string cafeId, ITenantContext tenant, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewPrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
var agents = await _db.PrintAgents
|
||||||
|
.Where(a => a.CafeId == cafeId)
|
||||||
|
.Include(a => a.Devices)
|
||||||
|
.OrderBy(a => a.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var dtos = agents.Select(a => new PrintAgentDto(
|
||||||
|
a.Id,
|
||||||
|
a.Name,
|
||||||
|
a.BranchId,
|
||||||
|
_registry.IsOnline(a.Id),
|
||||||
|
a.TokenHash is not null,
|
||||||
|
a.LastSeenAt,
|
||||||
|
a.CreatedAt,
|
||||||
|
a.Devices
|
||||||
|
.OrderBy(d => d.DisplayName)
|
||||||
|
.Select(d => new PrintAgentDeviceDto(d.Id, d.SystemName, d.DisplayName, d.Kind, d.LastSeenAt))
|
||||||
|
.ToList()
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<IReadOnlyList<PrintAgentDto>>(true, dtos));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Create a pending agent and a short one-time code the installer enters to pair.</summary>
|
||||||
|
[HttpPost("pairing-code")]
|
||||||
|
public async Task<IActionResult> CreatePairingCode(
|
||||||
|
string cafeId,
|
||||||
|
[FromBody] CreatePairingCodeRequest request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
var code = await GenerateUniqueCodeAsync(ct);
|
||||||
|
var agent = new PrintAgent
|
||||||
|
{
|
||||||
|
CafeId = cafeId,
|
||||||
|
BranchId = string.IsNullOrWhiteSpace(request.BranchId) ? null : request.BranchId,
|
||||||
|
Name = string.IsNullOrWhiteSpace(request.Name) ? "پرینتسرور" : request.Name!.Trim(),
|
||||||
|
PairingCode = code,
|
||||||
|
PairingCodeExpiresAt = DateTime.UtcNow.AddMinutes(15),
|
||||||
|
};
|
||||||
|
_db.PrintAgents.Add(agent);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<PairingCodeResponse>(
|
||||||
|
true, new PairingCodeResponse(agent.Id, code, agent.PairingCodeExpiresAt!.Value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Unpair/revoke an agent — it can no longer connect or print.</summary>
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> Revoke(string cafeId, string id, ITenantContext tenant, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
var agent = await _db.PrintAgents.FirstOrDefaultAsync(a => a.Id == id && a.CafeId == cafeId, ct);
|
||||||
|
if (agent is null)
|
||||||
|
return NotFound(new ApiResponse<object>(false, null, new ApiError("AGENT_NOT_FOUND", "Print agent not found.")));
|
||||||
|
|
||||||
|
agent.Revoked = true;
|
||||||
|
agent.TokenHash = null;
|
||||||
|
agent.PairingCode = null;
|
||||||
|
agent.DeletedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Send a test page to a discovered printer through its agent.</summary>
|
||||||
|
[HttpPost("devices/{deviceId}/test")]
|
||||||
|
public async Task<IActionResult> TestDevice(string cafeId, string deviceId, ITenantContext tenant, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
var result = await _printer.TestPrintDeviceAsync(cafeId, deviceId, ct);
|
||||||
|
return result.Success
|
||||||
|
? Ok(new ApiResponse<object>(true, null))
|
||||||
|
: BadRequest(new ApiResponse<object>(false, null,
|
||||||
|
new ApiError(result.ErrorCode ?? "PRINT_FAILED", result.ErrorDetail ?? "Test print failed.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ask the café's online agents to scan their LAN for devices (network
|
||||||
|
/// printers on :9100, card terminals on :8088) so the owner can pick instead of
|
||||||
|
/// typing an IP. Merges results across agents.</summary>
|
||||||
|
[HttpPost("scan")]
|
||||||
|
public async Task<IActionResult> Scan(
|
||||||
|
string cafeId,
|
||||||
|
[FromBody] ScanRequest request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
|
var online = _registry.OnlineAgentIdsForCafe(cafeId);
|
||||||
|
if (online.Count == 0)
|
||||||
|
return BadRequest(new ApiResponse<object>(
|
||||||
|
false, null, new ApiError("AGENT_OFFLINE", "No print agent is online to scan the network.")));
|
||||||
|
|
||||||
|
var ports = string.IsNullOrWhiteSpace(request.Ports) ? "9100,8088" : request.Ports!.Trim();
|
||||||
|
var merged = new Dictionary<string, ScannedDeviceDto>();
|
||||||
|
foreach (var agentId in online)
|
||||||
|
{
|
||||||
|
foreach (var d in await _registry.ScanAsync(agentId, ports, ct))
|
||||||
|
merged[$"{d.Ip}:{d.Port}"] = new ScannedDeviceDto(d.Ip, d.Port, d.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dtos = merged.Values.OrderBy(d => d.Ip).ThenBy(d => d.Port).ToList();
|
||||||
|
return Ok(new ApiResponse<IReadOnlyList<ScannedDeviceDto>>(true, dtos));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateUniqueCodeAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; attempt < 8; attempt++)
|
||||||
|
{
|
||||||
|
var code = RandomNumberGenerator.GetInt32(100_000, 1_000_000).ToString();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var clash = await _db.PrintAgents.IgnoreQueryFilters().AnyAsync(
|
||||||
|
a => a.PairingCode == code && a.PairingCodeExpiresAt > now && a.TokenHash == null, ct);
|
||||||
|
if (!clash) return code;
|
||||||
|
}
|
||||||
|
// Extremely unlikely; fall back to a longer code.
|
||||||
|
return RandomNumberGenerator.GetInt32(100_000, 1_000_000).ToString() +
|
||||||
|
RandomNumberGenerator.GetInt32(10, 100).ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Printing;
|
using Meezi.API.Models.Printing;
|
||||||
using Meezi.API.Services.Printing;
|
using Meezi.API.Services.Printing;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -32,16 +32,18 @@ public class PrintController : CafeApiControllerBase
|
|||||||
string cafeId,
|
string cafeId,
|
||||||
string orderId,
|
string orderId,
|
||||||
ITenantContext tenant,
|
ITenantContext tenant,
|
||||||
CancellationToken ct)
|
CancellationToken ct,
|
||||||
|
[FromQuery] string? stationId)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
|
||||||
var result = await _printer.PrintKitchenTicketAsync(cafeId, orderId, ct);
|
// stationId omitted → print every station (kitchen + bar …); provided →
|
||||||
|
// reprint only that one station's items.
|
||||||
|
var result = await _printer.PrintKitchenTicketAsync(cafeId, orderId, stationId, ct);
|
||||||
return ToActionResult(result);
|
return ToActionResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("test")]
|
[HttpPost("test")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> TestPrint(
|
public async Task<IActionResult> TestPrint(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
[FromBody] TestPrintRequest request,
|
[FromBody] TestPrintRequest request,
|
||||||
@@ -49,6 +51,7 @@ public class PrintController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManagePrintSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var result = await _printer.TestPrintAsync(request.PrinterIp, request.Port, ct);
|
var result = await _printer.TestPrintAsync(request.PrinterIp, request.Port, ct);
|
||||||
return ToActionResult(result);
|
return ToActionResult(result);
|
||||||
@@ -62,7 +65,8 @@ public class PrintController : CafeApiControllerBase
|
|||||||
|
|
||||||
var status = result.ErrorCode switch
|
var status = result.ErrorCode switch
|
||||||
{
|
{
|
||||||
"PRINTER_NOT_CONFIGURED" or "KITCHEN_PRINTER_NOT_CONFIGURED" => StatusCodes.Status400BadRequest,
|
"PRINTER_NOT_CONFIGURED" or "KITCHEN_PRINTER_NOT_CONFIGURED" or "NO_STATION_ITEMS"
|
||||||
|
=> StatusCodes.Status400BadRequest,
|
||||||
"ORDER_NOT_FOUND" => StatusCodes.Status404NotFound,
|
"ORDER_NOT_FOUND" => StatusCodes.Status404NotFound,
|
||||||
_ => StatusCodes.Status502BadGateway
|
_ => StatusCodes.Status502BadGateway
|
||||||
};
|
};
|
||||||
@@ -75,6 +79,7 @@ public class PrintController : CafeApiControllerBase
|
|||||||
{
|
{
|
||||||
"PRINTER_NOT_CONFIGURED" => "Receipt printer IP is not configured for this branch.",
|
"PRINTER_NOT_CONFIGURED" => "Receipt printer IP is not configured for this branch.",
|
||||||
"KITCHEN_PRINTER_NOT_CONFIGURED" => "Kitchen printer IP is not configured for this branch.",
|
"KITCHEN_PRINTER_NOT_CONFIGURED" => "Kitchen printer IP is not configured for this branch.",
|
||||||
|
"NO_STATION_ITEMS" => "This order has no items for the selected station.",
|
||||||
"PRINTER_CONNECTION_FAILED" => "Could not connect to the printer.",
|
"PRINTER_CONNECTION_FAILED" => "Could not connect to the printer.",
|
||||||
"ORDER_NOT_FOUND" => "Order not found.",
|
"ORDER_NOT_FOUND" => "Order not found.",
|
||||||
_ => "Print failed."
|
_ => "Print failed."
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
namespace Meezi.API.Controllers;
|
namespace Meezi.API.Controllers;
|
||||||
@@ -13,19 +16,19 @@ namespace Meezi.API.Controllers;
|
|||||||
///
|
///
|
||||||
/// POST /api/public/push/register — anonymous device registration
|
/// POST /api/public/push/register — anonymous device registration
|
||||||
/// POST /api/public/push/unregister — anonymous device removal
|
/// POST /api/public/push/unregister — anonymous device removal
|
||||||
/// POST /api/push/broadcast — authorized topic broadcast (marketing /
|
/// POST /api/push/broadcast — café marketing push (own topic only)
|
||||||
/// saved-café alerts)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
public class PushController : CafeApiControllerBase
|
||||||
public class PushController : ControllerBase
|
|
||||||
{
|
{
|
||||||
private readonly IPushDeviceService _devices;
|
private readonly IPushDeviceService _devices;
|
||||||
private readonly IPushSender _sender;
|
private readonly IPushSender _sender;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
public PushController(IPushDeviceService devices, IPushSender sender)
|
public PushController(IPushDeviceService devices, IPushSender sender, AppDbContext db)
|
||||||
{
|
{
|
||||||
_devices = devices;
|
_devices = devices;
|
||||||
_sender = sender;
|
_sender = sender;
|
||||||
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/public/push/register")]
|
[HttpPost("api/public/push/register")]
|
||||||
@@ -53,15 +56,26 @@ public class PushController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/push/broadcast")]
|
[HttpPost("api/push/broadcast")]
|
||||||
[Authorize]
|
|
||||||
public async Task<IActionResult> Broadcast(
|
public async Task<IActionResult> Broadcast(
|
||||||
[FromBody] BroadcastPushRequest request, CancellationToken ct)
|
[FromBody] BroadcastPushRequest request,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Topic))
|
if (EnsurePermission(tenant, Permission.SendSms) is { } forbidden) return forbidden;
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||||
new ApiError("INVALID_TOPIC", "Topic is required.")));
|
return StatusCode(StatusCodes.Status403Forbidden,
|
||||||
|
new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Café context is required.")));
|
||||||
|
|
||||||
await _sender.SendToTopicAsync(request.Topic, request.Title, request.Body, request.DeepLink, ct);
|
// A café may only push to its OWN topic (cafe-{slug}). The client-supplied
|
||||||
|
// topic is intentionally ignored to prevent cross-café / city-wide pushes.
|
||||||
|
var slug = await _db.Cafes.AsNoTracking()
|
||||||
|
.Where(c => c.Id == tenant.CafeId)
|
||||||
|
.Select(c => c.Slug)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(slug))
|
||||||
|
return NotFoundError("Café not found.");
|
||||||
|
|
||||||
|
await _sender.SendToTopicAsync($"cafe-{slug}", request.Title, request.Body, request.DeepLink, ct);
|
||||||
return Ok(new ApiResponse<object>(true, new { sent = true }));
|
return Ok(new ApiResponse<object>(true, new { sent = true }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Queue;
|
using Meezi.API.Models.Queue;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -37,6 +38,7 @@ public class QueueController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageQueue) is { } permDenied) return permDenied;
|
||||||
var (ticket, error) = await _queue.IssueNextAsync(cafeId, tenant.UserId, request, ct);
|
var (ticket, error) = await _queue.IssueNextAsync(cafeId, tenant.UserId, request, ct);
|
||||||
if (error == "BRANCH_NOT_FOUND")
|
if (error == "BRANCH_NOT_FOUND")
|
||||||
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Branch not found.")));
|
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Branch not found.")));
|
||||||
@@ -54,6 +56,7 @@ public class QueueController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageQueue) is { } permDenied) return permDenied;
|
||||||
var (ticket, error) = await _queue.UpdateStatusAsync(cafeId, ticketId, request.Status, ct);
|
var (ticket, error) = await _queue.UpdateStatusAsync(cafeId, ticketId, request.Status, ct);
|
||||||
if (error == "NOT_FOUND")
|
if (error == "NOT_FOUND")
|
||||||
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Ticket not found.")));
|
return NotFound(new ApiResponse<object>(false, null, new ApiError(error, "Ticket not found.")));
|
||||||
@@ -71,6 +74,7 @@ public class QueueController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageQueue) is { } permDenied) return permDenied;
|
||||||
var board = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
|
var board = await _queue.GetTodayBoardAsync(cafeId, branchId, ct);
|
||||||
var next = board.Tickets.FirstOrDefault(t => t.Status == QueueTicketStatus.Waiting);
|
var next = board.Tickets.FirstOrDefault(t => t.Status == QueueTicketStatus.Waiting);
|
||||||
if (next is null)
|
if (next is null)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Meezi.API.Models.Reports;
|
using Meezi.API.Models.Reports;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.API.Utils;
|
using Meezi.API.Utils;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Services.Platform;
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
@@ -38,6 +39,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrWhiteSpace(branchId))
|
if (string.IsNullOrWhiteSpace(branchId))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
|
new ApiError("VALIDATION_ERROR", "branchId is required.", "branchId")));
|
||||||
@@ -65,6 +67,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
if (!TryParseReportDate(from, out var startDate) || !TryParseReportDate(to, out var endDate))
|
if (!TryParseReportDate(from, out var startDate) || !TryParseReportDate(to, out var endDate))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
@@ -99,6 +102,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
var maxDays = await MaxHistoryDaysAsync(tenant, ct);
|
||||||
if (days > maxDays && maxDays != int.MaxValue)
|
if (days > maxDays && maxDays != int.MaxValue)
|
||||||
@@ -120,6 +124,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
if (date is not null && !JalaliCalendarHelper.TryParseJalaliDate(date, out _, out _, out _))
|
if (date is not null && !JalaliCalendarHelper.TryParseJalaliDate(date, out _, out _, out _))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali date. Use yyyy-MM-dd.")));
|
new ApiError("VALIDATION_ERROR", "Invalid Jalali date. Use yyyy-MM-dd.")));
|
||||||
@@ -136,6 +141,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
if (month is not null && !JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
|
if (month is not null && !JalaliCalendarHelper.TryParseJalaliMonth(month, out _, out _))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
|
new ApiError("VALIDATION_ERROR", "Invalid Jalali month. Use yyyy-MM.")));
|
||||||
@@ -152,6 +158,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ViewReports) is { } permDenied) return permDenied;
|
||||||
var data = await _reports.GetTrendAsync(cafeId, days, ct);
|
var data = await _reports.GetTrendAsync(cafeId, days, ct);
|
||||||
return Ok(new ApiResponse<IReadOnlyList<TrendDayDto>>(true, data));
|
return Ok(new ApiResponse<IReadOnlyList<TrendDayDto>>(true, data));
|
||||||
}
|
}
|
||||||
@@ -165,6 +172,7 @@ public class ReportsController : CafeApiControllerBase
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ExportReports) is { } permDenied) return permDenied;
|
||||||
if (!string.Equals(format, "excel", StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(format, "excel", StringComparison.OrdinalIgnoreCase))
|
||||||
return BadRequest(new ApiResponse<object>(false, null,
|
return BadRequest(new ApiResponse<object>(false, null,
|
||||||
new ApiError("VALIDATION_ERROR", "Only excel format is supported.")));
|
new ApiError("VALIDATION_ERROR", "Only excel format is supported.")));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
@@ -30,6 +31,7 @@ public class ReservationsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.CreateReservation) is { } permDenied) return permDenied;
|
||||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ public class ReservationsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.EditReservation) is { } permDenied) return permDenied;
|
||||||
var data = await _reservations.UpdateStatusAsync(cafeId, id, request.Status, ct);
|
var data = await _reservations.UpdateStatusAsync(cafeId, id, request.Status, ct);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<ReservationDto>(true, data));
|
return Ok(new ApiResponse<ReservationDto>(true, data));
|
||||||
@@ -75,6 +78,7 @@ public class ReservationsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.DeleteReservation) is { } permDenied) return permDenied;
|
||||||
var deleted = await _reservations.DeleteAsync(cafeId, id, ct);
|
var deleted = await _reservations.DeleteAsync(cafeId, id, ct);
|
||||||
if (!deleted) return NotFoundError();
|
if (!deleted) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id }));
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Shifts;
|
using Meezi.API.Models.Shifts;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ public class ShiftsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.OperateRegister) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrEmpty(tenant.UserId))
|
if (string.IsNullOrEmpty(tenant.UserId))
|
||||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||||
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
||||||
@@ -54,6 +56,7 @@ public class ShiftsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.OperateRegister) is { } permDenied) return permDenied;
|
||||||
if (string.IsNullOrEmpty(tenant.UserId))
|
if (string.IsNullOrEmpty(tenant.UserId))
|
||||||
return StatusCode(StatusCodes.Status401Unauthorized,
|
return StatusCode(StatusCodes.Status401Unauthorized,
|
||||||
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
new ApiResponse<object>(false, null, new ApiError("UNAUTHORIZED", "User context is missing.")));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FluentValidation;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Crm;
|
using Meezi.API.Models.Crm;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ public class SmsController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureManager(tenant) is { } forbidden) return forbidden;
|
if (EnsurePermission(tenant, Permission.ManageSmsSettings) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var (success, data, code, message) = await _smsMarketingService.UpdateSettingsAsync(
|
var (success, data, code, message) = await _smsMarketingService.UpdateSettingsAsync(
|
||||||
cafeId, request, cancellationToken);
|
cafeId, request, cancellationToken);
|
||||||
@@ -85,6 +86,7 @@ public class SmsController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.SendSms) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
|
var validation = await _campaignValidator.ValidateAsync(request, cancellationToken);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Orders;
|
using Meezi.API.Models.Orders;
|
||||||
using Meezi.API.Models.Tables;
|
using Meezi.API.Models.Tables;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -65,6 +65,7 @@ public class TablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
var validation = await _createValidator.ValidateAsync(request, ct);
|
var validation = await _createValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ public class TablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
var validation = await _patchValidator.ValidateAsync(request, ct);
|
var validation = await _patchValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
@@ -104,7 +106,6 @@ public class TablesController : CafeApiControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[Authorize(Roles = "Manager,Owner")]
|
|
||||||
public async Task<IActionResult> DeleteTable(
|
public async Task<IActionResult> DeleteTable(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string id,
|
string id,
|
||||||
@@ -112,6 +113,7 @@ public class TablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var result = await _tableService.DeleteTableAsync(cafeId, id, ct);
|
var result = await _tableService.DeleteTableAsync(cafeId, id, ct);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
@@ -135,6 +137,7 @@ public class TablesController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageTables) is { } permDenied) return permDenied;
|
||||||
var validation = await _cleaningValidator.ValidateAsync(request, ct);
|
var validation = await _cleaningValidator.ValidateAsync(request, ct);
|
||||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ public class TarazController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageFinancials) is { } permDenied) return permDenied;
|
||||||
|
|
||||||
var targetDate = date ?? DateTime.UtcNow.Date;
|
var targetDate = date ?? DateTime.UtcNow.Date;
|
||||||
var result = await _taraz.SubmitDailyInvoicesAsync(cafeId, targetDate, ct);
|
var result = await _taraz.SubmitDailyInvoicesAsync(cafeId, targetDate, ct);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Meezi.API.Models.Taxes;
|
using Meezi.API.Models.Taxes;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Shared;
|
using Meezi.Shared;
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ public class TaxesController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
if (EnsurePermission(tenant, Permission.CreateTax) is { } permDenied) return permDenied;
|
||||||
var data = await _taxService.CreateAsync(cafeId, request, cancellationToken);
|
var data = await _taxService.CreateAsync(cafeId, request, cancellationToken);
|
||||||
return Ok(new ApiResponse<TaxDto>(true, data));
|
return Ok(new ApiResponse<TaxDto>(true, data));
|
||||||
}
|
}
|
||||||
@@ -43,7 +44,7 @@ public class TaxesController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
if (EnsurePermission(tenant, Permission.EditTax) is { } permDenied) return permDenied;
|
||||||
var data = await _taxService.UpdateAsync(cafeId, id, request, cancellationToken);
|
var data = await _taxService.UpdateAsync(cafeId, id, request, cancellationToken);
|
||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<TaxDto>(true, data));
|
return Ok(new ApiResponse<TaxDto>(true, data));
|
||||||
@@ -57,7 +58,7 @@ public class TaxesController : CafeApiControllerBase
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
|
if (EnsurePermission(tenant, Permission.DeleteTax) is { } permDenied) return permDenied;
|
||||||
var deleted = await _taxService.DeleteAsync(cafeId, id, cancellationToken);
|
var deleted = await _taxService.DeleteAsync(cafeId, id, cancellationToken);
|
||||||
if (!deleted) return NotFoundError();
|
if (!deleted) return NotFoundError();
|
||||||
return Ok(new ApiResponse<object>(true, new { id }));
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Meezi.Core.Authorization;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
@@ -52,6 +53,7 @@ public class TerminalsController : CafeApiControllerBase
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
if (EnsurePermission(tenant, Permission.ManageCafeSettings) is { } permDenied) return permDenied;
|
||||||
await _terminals.RevokeAsync(cafeId, terminalId, ct);
|
await _terminals.RevokeAsync(cafeId, terminalId, ct);
|
||||||
return Ok(new ApiResponse<object>(true, new { revoked = true }));
|
return Ok(new ApiResponse<object>(true, new { revoked = true }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using Meezi.API.Services;
|
|||||||
using Meezi.API.Services.Delivery;
|
using Meezi.API.Services.Delivery;
|
||||||
using Meezi.Infrastructure.Services.Platform;
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Meezi.API.Services.Printing;
|
using Meezi.API.Services.Printing;
|
||||||
|
using Meezi.API.Services.Payments;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure;
|
using Meezi.Infrastructure;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -94,6 +95,14 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IDemoSeedService, DemoSeedService>();
|
services.AddScoped<IDemoSeedService, DemoSeedService>();
|
||||||
services.AddScoped<ReceiptBuilder>();
|
services.AddScoped<ReceiptBuilder>();
|
||||||
services.AddScoped<IPrinterService, NetworkPrinterService>();
|
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.AddHttpClient(nameof(PosDeviceService));
|
||||||
services.AddScoped<IPosDeviceService, PosDeviceService>();
|
services.AddScoped<IPosDeviceService, PosDeviceService>();
|
||||||
services.AddScoped<SubscriptionRenewalReminderJob>();
|
services.AddScoped<SubscriptionRenewalReminderJob>();
|
||||||
@@ -224,6 +233,7 @@ public static class ServiceCollectionExtensions
|
|||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapHub<KdsHub>("/hubs/kds");
|
app.MapHub<KdsHub>("/hubs/kds");
|
||||||
app.MapHub<GuestOrderHub>("/hubs/guest-order");
|
app.MapHub<GuestOrderHub>("/hubs/guest-order");
|
||||||
|
app.MapHub<PrintAgentHub>("/hubs/print-agent");
|
||||||
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
|
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
|
||||||
|
|
||||||
if (!app.Configuration.GetValue<bool>("Testing:Enabled"))
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,18 +7,21 @@ public record KitchenStationDto(
|
|||||||
string? PrinterIp,
|
string? PrinterIp,
|
||||||
int PrinterPort,
|
int PrinterPort,
|
||||||
int SortOrder,
|
int SortOrder,
|
||||||
int CategoryCount);
|
int CategoryCount,
|
||||||
|
string? PrintDeviceId);
|
||||||
|
|
||||||
public record CreateKitchenStationRequest(
|
public record CreateKitchenStationRequest(
|
||||||
string Name,
|
string Name,
|
||||||
string? BranchId,
|
string? BranchId,
|
||||||
string? PrinterIp,
|
string? PrinterIp,
|
||||||
int PrinterPort = 9100,
|
int PrinterPort = 9100,
|
||||||
int SortOrder = 0);
|
int SortOrder = 0,
|
||||||
|
string? PrintDeviceId = null);
|
||||||
|
|
||||||
public record UpdateKitchenStationRequest(
|
public record UpdateKitchenStationRequest(
|
||||||
string? Name,
|
string? Name,
|
||||||
string? BranchId,
|
string? BranchId,
|
||||||
string? PrinterIp,
|
string? PrinterIp,
|
||||||
int? PrinterPort,
|
int? PrinterPort,
|
||||||
int? SortOrder);
|
int? SortOrder,
|
||||||
|
string? PrintDeviceId);
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ public record MenuItemDto(
|
|||||||
string? ImageUrl,
|
string? ImageUrl,
|
||||||
string? VideoUrl,
|
string? VideoUrl,
|
||||||
string? Model3dUrl,
|
string? Model3dUrl,
|
||||||
bool IsAvailable);
|
bool IsAvailable,
|
||||||
|
string? KitchenStationId);
|
||||||
|
|
||||||
public record CreateMenuItemRequest(
|
public record CreateMenuItemRequest(
|
||||||
string CategoryId,
|
string CategoryId,
|
||||||
@@ -68,7 +69,8 @@ public record CreateMenuItemRequest(
|
|||||||
string? ImageUrl = null,
|
string? ImageUrl = null,
|
||||||
string? VideoUrl = null,
|
string? VideoUrl = null,
|
||||||
string? Model3dUrl = null,
|
string? Model3dUrl = null,
|
||||||
bool IsAvailable = true);
|
bool IsAvailable = true,
|
||||||
|
string? KitchenStationId = null);
|
||||||
|
|
||||||
public record UpdateMenuItemRequest(
|
public record UpdateMenuItemRequest(
|
||||||
string? CategoryId,
|
string? CategoryId,
|
||||||
@@ -81,6 +83,7 @@ public record UpdateMenuItemRequest(
|
|||||||
string? ImageUrl,
|
string? ImageUrl,
|
||||||
string? VideoUrl,
|
string? VideoUrl,
|
||||||
string? Model3dUrl,
|
string? Model3dUrl,
|
||||||
bool? IsAvailable);
|
bool? IsAvailable,
|
||||||
|
string? KitchenStationId);
|
||||||
|
|
||||||
public record UpdateMenuItemAvailabilityRequest(bool IsAvailable);
|
public record UpdateMenuItemAvailabilityRequest(bool IsAvailable);
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ public record OrderItemDto(
|
|||||||
decimal UnitPrice,
|
decimal UnitPrice,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
bool IsVoided = false,
|
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);
|
public record TransferTableRequest(string TargetTableId);
|
||||||
|
|
||||||
|
|||||||
@@ -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? ReceiptFooter,
|
||||||
string? WifiPassword,
|
string? WifiPassword,
|
||||||
string? PosDeviceIp,
|
string? PosDeviceIp,
|
||||||
int? PosDevicePort);
|
int? PosDevicePort,
|
||||||
|
string? ReceiptPrintDeviceId,
|
||||||
|
string? KitchenPrintDeviceId);
|
||||||
|
|
||||||
public record PatchBranchPrintSettingsRequest(
|
public record PatchBranchPrintSettingsRequest(
|
||||||
string? ReceiptPrinterIp,
|
string? ReceiptPrinterIp,
|
||||||
@@ -25,7 +27,9 @@ public record PatchBranchPrintSettingsRequest(
|
|||||||
string? ReceiptFooter,
|
string? ReceiptFooter,
|
||||||
string? WifiPassword,
|
string? WifiPassword,
|
||||||
string? PosDeviceIp,
|
string? PosDeviceIp,
|
||||||
int? PosDevicePort);
|
int? PosDevicePort,
|
||||||
|
string? ReceiptPrintDeviceId,
|
||||||
|
string? KitchenPrintDeviceId);
|
||||||
|
|
||||||
public record PosPaymentRequest(string OrderId, decimal Amount);
|
public record PosPaymentRequest(string OrderId, decimal Amount);
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ public interface IBillingService
|
|||||||
string cafeId,
|
string cafeId,
|
||||||
string paymentId,
|
string paymentId,
|
||||||
CancellationToken cancellationToken = default);
|
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
|
public class BillingService : IBillingService
|
||||||
@@ -217,6 +232,16 @@ public class BillingService : IBillingService
|
|||||||
|
|
||||||
payment.RefId = verify.RefId;
|
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 cafe = payment.Cafe;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
@@ -244,8 +269,75 @@ public class BillingService : IBillingService
|
|||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
await TrySendConfirmationSmsAsync(cafe, payment, queued, 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
|
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ public class KitchenStationService : IKitchenStationService
|
|||||||
s.PrinterIp,
|
s.PrinterIp,
|
||||||
s.PrinterPort,
|
s.PrinterPort,
|
||||||
s.SortOrder,
|
s.SortOrder,
|
||||||
|
s.PrintDeviceId,
|
||||||
CategoryCount = s.Categories.Count(c => c.DeletedAt == null)
|
CategoryCount = s.Categories.Count(c => c.DeletedAt == null)
|
||||||
})
|
})
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return stations.Select(s => new KitchenStationDto(
|
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(
|
public async Task<KitchenStationDto?> CreateAsync(
|
||||||
@@ -60,6 +61,7 @@ public class KitchenStationService : IKitchenStationService
|
|||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(),
|
PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim(),
|
||||||
PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100,
|
PrinterPort = request.PrinterPort > 0 ? request.PrinterPort : 9100,
|
||||||
|
PrintDeviceId = string.IsNullOrWhiteSpace(request.PrintDeviceId) ? null : request.PrintDeviceId,
|
||||||
SortOrder = request.SortOrder
|
SortOrder = request.SortOrder
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,6 +97,8 @@ public class KitchenStationService : IKitchenStationService
|
|||||||
entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim();
|
entity.PrinterIp = string.IsNullOrWhiteSpace(request.PrinterIp) ? null : request.PrinterIp.Trim();
|
||||||
if (request.PrinterPort.HasValue)
|
if (request.PrinterPort.HasValue)
|
||||||
entity.PrinterPort = request.PrinterPort.Value > 0 ? request.PrinterPort.Value : 9100;
|
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)
|
if (request.SortOrder.HasValue)
|
||||||
entity.SortOrder = request.SortOrder.Value;
|
entity.SortOrder = request.SortOrder.Value;
|
||||||
|
|
||||||
@@ -114,6 +118,12 @@ public class KitchenStationService : IKitchenStationService
|
|||||||
foreach (var cat in categories)
|
foreach (var cat in categories)
|
||||||
cat.KitchenStationId = null;
|
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;
|
entity.DeletedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return true;
|
return true;
|
||||||
@@ -131,12 +141,13 @@ public class KitchenStationService : IKitchenStationService
|
|||||||
x.PrinterIp,
|
x.PrinterIp,
|
||||||
x.PrinterPort,
|
x.PrinterPort,
|
||||||
x.SortOrder,
|
x.SortOrder,
|
||||||
|
x.PrintDeviceId,
|
||||||
CategoryCount = x.Categories.Count(c => c.DeletedAt == null)
|
CategoryCount = x.Categories.Count(c => c.DeletedAt == null)
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
return s is null
|
return s is null
|
||||||
? 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,
|
ImageUrl = imageUrl,
|
||||||
VideoUrl = request.VideoUrl,
|
VideoUrl = request.VideoUrl,
|
||||||
Model3dUrl = NormalizeOptionalText(request.Model3dUrl),
|
Model3dUrl = NormalizeOptionalText(request.Model3dUrl),
|
||||||
IsAvailable = request.IsAvailable
|
IsAvailable = request.IsAvailable,
|
||||||
|
KitchenStationId = string.IsNullOrWhiteSpace(request.KitchenStationId) ? null : request.KitchenStationId,
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.MenuItems.Add(entity);
|
_db.MenuItems.Add(entity);
|
||||||
@@ -178,6 +179,8 @@ public class MenuService : IMenuService
|
|||||||
if (request.Model3dUrl is not null)
|
if (request.Model3dUrl is not null)
|
||||||
entity.Model3dUrl = string.IsNullOrWhiteSpace(request.Model3dUrl) ? null : request.Model3dUrl.Trim();
|
entity.Model3dUrl = string.IsNullOrWhiteSpace(request.Model3dUrl) ? null : request.Model3dUrl.Trim();
|
||||||
if (request.IsAvailable.HasValue) entity.IsAvailable = request.IsAvailable.Value;
|
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);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
return ToItemDto(entity);
|
return ToItemDto(entity);
|
||||||
@@ -236,5 +239,6 @@ public class MenuService : IMenuService
|
|||||||
MenuItemImageDefaults.ResolveDisplayImageUrl(i),
|
MenuItemImageDefaults.ResolveDisplayImageUrl(i),
|
||||||
i.VideoUrl,
|
i.VideoUrl,
|
||||||
i.Model3dUrl,
|
i.Model3dUrl,
|
||||||
i.IsAvailable);
|
i.IsAvailable,
|
||||||
|
i.KitchenStationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ public class OrderService : IOrderService
|
|||||||
var orders = await _db.Orders
|
var orders = await _db.Orders
|
||||||
.Include(o => o.Items)
|
.Include(o => o.Items)
|
||||||
.ThenInclude(i => i.MenuItem)
|
.ThenInclude(i => i.MenuItem)
|
||||||
|
.ThenInclude(m => m.Category)
|
||||||
|
.ThenInclude(c => c.KitchenStation)
|
||||||
.Include(o => o.Table)
|
.Include(o => o.Table)
|
||||||
.Where(o => o.CafeId == cafeId && LiveStatuses.Contains(o.Status))
|
.Where(o => o.CafeId == cafeId && LiveStatuses.Contains(o.Status))
|
||||||
.OrderBy(o => o.CreatedAt)
|
.OrderBy(o => o.CreatedAt)
|
||||||
@@ -993,9 +995,18 @@ public class OrderService : IOrderService
|
|||||||
if (order.Status == OrderStatus.Cancelled)
|
if (order.Status == OrderStatus.Cancelled)
|
||||||
return new OrderServiceResult<OrderDto>(false, null, "ORDER_ALREADY_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");
|
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
|
// 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.
|
// here would silently strip the recorded money. Block and surface the reason.
|
||||||
if (order.Payments.Any(p => p.DeletedAt == null))
|
if (order.Payments.Any(p => p.DeletedAt == null))
|
||||||
@@ -1037,6 +1048,12 @@ public class OrderService : IOrderService
|
|||||||
if (order is null)
|
if (order is null)
|
||||||
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "ORDER_NOT_FOUND");
|
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);
|
var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken);
|
||||||
if (string.IsNullOrEmpty(branchId))
|
if (string.IsNullOrEmpty(branchId))
|
||||||
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "NO_OPEN_SHIFT", "branchId");
|
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(false, null, "NO_OPEN_SHIFT", "branchId");
|
||||||
@@ -1123,7 +1140,8 @@ public class OrderService : IOrderService
|
|||||||
|
|
||||||
if (paidTotal >= order.Total)
|
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 _loyalty.ApplyEarnOnOrderPaidAsync(cafeId, order.CustomerId, paidTotal, cancellationToken);
|
||||||
await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Delivered, cancellationToken);
|
await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Delivered, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -1345,6 +1363,8 @@ public class OrderService : IOrderService
|
|||||||
i.UnitPrice,
|
i.UnitPrice,
|
||||||
i.Notes,
|
i.Notes,
|
||||||
i.IsVoided,
|
i.IsVoided,
|
||||||
i.VoidedAt)).ToList(),
|
i.VoidedAt,
|
||||||
|
i.MenuItem?.Category?.KitchenStationId,
|
||||||
|
i.MenuItem?.Category?.KitchenStation?.Name)).ToList(),
|
||||||
o.Source);
|
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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Meezi.API.Models.Printing;
|
using Meezi.API.Models.Printing;
|
||||||
|
using Meezi.API.Services.Printing;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -29,15 +30,18 @@ public class PosDeviceService : IPosDeviceService
|
|||||||
|
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IPrintAgentRegistry _agents;
|
||||||
private readonly ILogger<PosDeviceService> _logger;
|
private readonly ILogger<PosDeviceService> _logger;
|
||||||
|
|
||||||
public PosDeviceService(
|
public PosDeviceService(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IPrintAgentRegistry agents,
|
||||||
ILogger<PosDeviceService> logger)
|
ILogger<PosDeviceService> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_agents = agents;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +75,31 @@ public class PosDeviceService : IPosDeviceService
|
|||||||
if (order is null)
|
if (order is null)
|
||||||
return PosDeviceResult.Fail("ORDER_NOT_FOUND");
|
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
|
var payload = new
|
||||||
{
|
{
|
||||||
amount = (long)Math.Round(request.Amount, 0, MidpointRounding.AwayFromZero),
|
amount,
|
||||||
orderId = request.OrderId,
|
orderId = request.OrderId,
|
||||||
branchId,
|
branchId,
|
||||||
};
|
};
|
||||||
|
|
||||||
var url = $"http://{branch.PosDeviceIp!.Trim()}:{port}/pay";
|
var url = $"http://{ip}:{port}/pay";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -117,4 +138,31 @@ public class PosDeviceService : IPosDeviceService
|
|||||||
return PosDeviceResult.Fail("POS_DEVICE_CONNECTION_FAILED", ex.Message);
|
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(
|
Task<PrintResult> PrintKitchenTicketAsync(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string orderId,
|
string orderId,
|
||||||
|
string? stationId = null,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
Task<PrintResult> TestPrintAsync(string printerIp, int port, 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
|
public class NetworkPrinterService : IPrinterService
|
||||||
@@ -28,17 +30,20 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IOrderService _orders;
|
private readonly IOrderService _orders;
|
||||||
private readonly ReceiptBuilder _receiptBuilder;
|
private readonly ReceiptBuilder _receiptBuilder;
|
||||||
|
private readonly IPrintAgentRegistry _agents;
|
||||||
private readonly ILogger<NetworkPrinterService> _logger;
|
private readonly ILogger<NetworkPrinterService> _logger;
|
||||||
|
|
||||||
public NetworkPrinterService(
|
public NetworkPrinterService(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
IOrderService orders,
|
IOrderService orders,
|
||||||
ReceiptBuilder receiptBuilder,
|
ReceiptBuilder receiptBuilder,
|
||||||
|
IPrintAgentRegistry agents,
|
||||||
ILogger<NetworkPrinterService> logger)
|
ILogger<NetworkPrinterService> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_orders = orders;
|
_orders = orders;
|
||||||
_receiptBuilder = receiptBuilder;
|
_receiptBuilder = receiptBuilder;
|
||||||
|
_agents = agents;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +53,16 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
if (ctx is null)
|
if (ctx is null)
|
||||||
return PrintResult.Fail("ORDER_NOT_FOUND");
|
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");
|
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
|
||||||
|
|
||||||
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
|
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
|
||||||
return await SendToPrinterAsync(
|
return await DispatchAsync(
|
||||||
ctx.Value.branch.ReceiptPrinterIp!,
|
cafeId,
|
||||||
ctx.Value.branch.ReceiptPrinterPort ?? 9100,
|
branch.ReceiptPrintDeviceId,
|
||||||
|
branch.ReceiptPrinterIp,
|
||||||
|
branch.ReceiptPrinterPort ?? 9100,
|
||||||
bytes,
|
bytes,
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
@@ -62,6 +70,7 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
public async Task<PrintResult> PrintKitchenTicketAsync(
|
public async Task<PrintResult> PrintKitchenTicketAsync(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string orderId,
|
string orderId,
|
||||||
|
string? stationId = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var ctx = await BuildContextAsync(cafeId, orderId, ct);
|
var ctx = await BuildContextAsync(cafeId, orderId, ct);
|
||||||
@@ -74,15 +83,16 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
return PrintResult.Ok();
|
return PrintResult.Ok();
|
||||||
|
|
||||||
var menuItemIds = activeItems.Select(i => i.MenuItemId).Distinct().ToList();
|
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()
|
from m in _db.MenuItems.AsNoTracking()
|
||||||
join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id
|
join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id
|
||||||
where menuItemIds.Contains(m.Id) && m.CafeId == cafeId
|
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);
|
).ToListAsync(ct);
|
||||||
|
|
||||||
var stationIds = categoryStations
|
var stationIds = itemStations
|
||||||
.Select(x => x.KitchenStationId)
|
.Select(x => x.StationId)
|
||||||
.Where(id => !string.IsNullOrEmpty(id))
|
.Where(id => !string.IsNullOrEmpty(id))
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -97,11 +107,19 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
var groups = activeItems
|
var groups = activeItems
|
||||||
.GroupBy(item =>
|
.GroupBy(item =>
|
||||||
{
|
{
|
||||||
var cat = categoryStations.FirstOrDefault(c => c.Id == item.MenuItemId);
|
var map = itemStations.FirstOrDefault(c => c.Id == item.MenuItemId);
|
||||||
return cat?.KitchenStationId;
|
return map?.StationId;
|
||||||
})
|
})
|
||||||
.ToList();
|
.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;
|
PrintResult? lastFail = null;
|
||||||
var anyPrinted = false;
|
var anyPrinted = false;
|
||||||
|
|
||||||
@@ -111,18 +129,21 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
? null
|
? null
|
||||||
: stations.FirstOrDefault(s => s.Id == group.Key);
|
: stations.FirstOrDefault(s => s.Id == group.Key);
|
||||||
|
|
||||||
|
string? deviceId;
|
||||||
string? ip;
|
string? ip;
|
||||||
int port;
|
int port;
|
||||||
string? stationLabel = null;
|
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;
|
ip = station.PrinterIp;
|
||||||
port = station.PrinterPort;
|
port = station.PrinterPort;
|
||||||
stationLabel = station.Name;
|
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;
|
ip = ctx.Value.branch.KitchenPrinterIp;
|
||||||
port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
|
port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
|
||||||
}
|
}
|
||||||
@@ -136,7 +157,7 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
var bytes = _receiptBuilder.BuildKitchenTicket(
|
var bytes = _receiptBuilder.BuildKitchenTicket(
|
||||||
ctx.Value.printCtx with { StationName = stationLabel },
|
ctx.Value.printCtx with { StationName = stationLabel },
|
||||||
itemsOnly);
|
itemsOnly);
|
||||||
var result = await SendToPrinterAsync(ip!, port, bytes, ct);
|
var result = await DispatchAsync(cafeId, deviceId, ip, port, bytes, ct);
|
||||||
if (result.Success)
|
if (result.Success)
|
||||||
anyPrinted = true;
|
anyPrinted = true;
|
||||||
else
|
else
|
||||||
@@ -155,6 +176,66 @@ public class NetworkPrinterService : IPrinterService
|
|||||||
return await SendToPrinterAsync(printerIp.Trim(), port, bytes, ct);
|
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(
|
private async Task<(Branch branch, ReceiptPrintContext printCtx)?> BuildContextAsync(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string orderId,
|
string orderId,
|
||||||
@@ -243,7 +324,7 @@ public static class PrinterBackgroundJobs
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>();
|
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)
|
if (!result.Success)
|
||||||
logger.LogWarning("Kitchen print failed for {OrderId}: {Code}", orderId, result.ErrorCode);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,12 @@
|
|||||||
"MerchantId": "",
|
"MerchantId": "",
|
||||||
"Sandbox": true
|
"Sandbox": true
|
||||||
},
|
},
|
||||||
|
"FlatPay": {
|
||||||
|
"BaseUrl": "https://pay.flatrender.ir",
|
||||||
|
"ReturnUrl": "https://meezi.ir/payment/return",
|
||||||
|
"ApiKey": "",
|
||||||
|
"Secret": ""
|
||||||
|
},
|
||||||
"Billing": {
|
"Billing": {
|
||||||
"DashboardBaseUrl": "http://localhost:3101"
|
"DashboardBaseUrl": "http://localhost:3101"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ public class AdminCafesController : AdminApiControllerBase
|
|||||||
return Ok(new ApiResponse<object>(true, new { cafeId }));
|
return Ok(new ApiResponse<object>(true, new { cafeId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Gift a café a free subscription (set plan + add N months of coverage).</summary>
|
||||||
|
[HttpPost("{cafeId}/grant-subscription")]
|
||||||
|
public async Task<IActionResult> GrantSubscription(
|
||||||
|
string cafeId,
|
||||||
|
[FromBody] AdminGrantSubscriptionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.Months is < 1 or > 120)
|
||||||
|
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID_MONTHS", "Months must be 1–120.")));
|
||||||
|
|
||||||
|
var ok = await _platform.GrantSubscriptionAsync(cafeId, request.PlanTier, request.Months, cancellationToken);
|
||||||
|
if (!ok)
|
||||||
|
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found or invalid plan.")));
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, new { cafeId }));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("{cafeId}/features")]
|
[HttpPut("{cafeId}/features")]
|
||||||
public async Task<IActionResult> SetFeature(
|
public async Task<IActionResult> SetFeature(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
|
|||||||
@@ -50,4 +50,8 @@ public record AdminCafePatchRequest(
|
|||||||
bool? IsVerified,
|
bool? IsVerified,
|
||||||
IReadOnlyList<string>? DiscoverBadges = null);
|
IReadOnlyList<string>? DiscoverBadges = null);
|
||||||
|
|
||||||
|
/// <summary>Admin gifts a café a free subscription: set the plan and add <see cref="Months"/>
|
||||||
|
/// of coverage (appended to any time it already has).</summary>
|
||||||
|
public record AdminGrantSubscriptionRequest(PlanTier PlanTier, int Months);
|
||||||
|
|
||||||
public record CafeFeatureOverrideRequest(string FeatureKey, bool IsEnabled);
|
public record CafeFeatureOverrideRequest(string FeatureKey, bool IsEnabled);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public interface IAdminPlatformService
|
|||||||
Task<bool> UpdateFeatureAsync(string featureKey, UpdateFeatureRequest request, CancellationToken cancellationToken = default);
|
Task<bool> UpdateFeatureAsync(string featureKey, UpdateFeatureRequest request, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyList<AdminCafeListItemDto>> ListCafesAsync(CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<AdminCafeListItemDto>> ListCafesAsync(CancellationToken cancellationToken = default);
|
||||||
Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default);
|
Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> GrantSubscriptionAsync(string cafeId, PlanTier tier, int months, CancellationToken cancellationToken = default);
|
||||||
Task<(bool Success, string? Key, string? ErrorCode)> GenerateRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
|
Task<(bool Success, string? Key, string? ErrorCode)> GenerateRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||||
Task<bool> RevokeRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
|
Task<bool> RevokeRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||||
Task<bool> SetCafeFeatureOverrideAsync(string cafeId, CafeFeatureOverrideRequest request, CancellationToken cancellationToken = default);
|
Task<bool> SetCafeFeatureOverrideAsync(string cafeId, CafeFeatureOverrideRequest request, CancellationToken cancellationToken = default);
|
||||||
@@ -207,6 +208,44 @@ public class AdminPlatformService : IAdminPlatformService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> GrantSubscriptionAsync(
|
||||||
|
string cafeId, PlanTier tier, int months, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (tier == PlanTier.Free || months is < 1 or > 120)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||||
|
if (cafe is null) return false;
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
// Append to existing paid coverage so a grant never shortens time the café already has.
|
||||||
|
var coverageEnd = cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now
|
||||||
|
? cafe.PlanExpiresAt.Value
|
||||||
|
: now;
|
||||||
|
var newExpiry = coverageEnd.AddMonths(months);
|
||||||
|
|
||||||
|
cafe.PlanTier = tier;
|
||||||
|
cafe.PlanExpiresAt = newExpiry;
|
||||||
|
|
||||||
|
// Record the gift for billing history / audit (free → amount 0, provider Manual).
|
||||||
|
_db.SubscriptionPayments.Add(new SubscriptionPayment
|
||||||
|
{
|
||||||
|
CafeId = cafeId,
|
||||||
|
PlanTier = tier,
|
||||||
|
Months = months,
|
||||||
|
AmountToman = 0m,
|
||||||
|
AmountRials = 0,
|
||||||
|
Provider = PaymentProvider.Manual,
|
||||||
|
Status = SubscriptionPaymentStatus.Completed,
|
||||||
|
EffectiveFrom = now,
|
||||||
|
EffectiveTo = newExpiry,
|
||||||
|
RefId = "admin-grant",
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> SetCafeFeatureOverrideAsync(
|
public async Task<bool> SetCafeFeatureOverrideAsync(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
CafeFeatureOverrideRequest request,
|
CafeFeatureOverrideRequest request,
|
||||||
|
|||||||
@@ -5,37 +5,135 @@ namespace Meezi.Core.Authorization;
|
|||||||
/// truth for authorization — controllers check a <see cref="Permission"/> rather
|
/// truth for authorization — controllers check a <see cref="Permission"/> rather
|
||||||
/// than hard-coding role names, so the role→capability mapping lives in exactly
|
/// than hard-coding role names, so the role→capability mapping lives in exactly
|
||||||
/// one place (<see cref="RolePermissions"/>).
|
/// one place (<see cref="RolePermissions"/>).
|
||||||
|
///
|
||||||
|
/// Granularity is "full CRUD per module + distinct sensitive actions": each page
|
||||||
|
/// has a View capability, record modules split Create/Edit/Delete, and high-risk
|
||||||
|
/// operations (void, refund, discount, comp, cash drawer, export) are their own
|
||||||
|
/// permissions so an owner can grant day-to-day work without the dangerous bits.
|
||||||
|
///
|
||||||
|
/// Names are persisted (custom roles store them by name in JSON, and they ride in
|
||||||
|
/// the JWT). Renaming or removing a value is a breaking change — add, don't rename.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum Permission
|
public enum Permission
|
||||||
{
|
{
|
||||||
// Café-level administration (Owner only)
|
// ── Café administration (owner tier) ──────────────────────────────────────
|
||||||
|
ViewCafeSettings,
|
||||||
ManageCafeSettings,
|
ManageCafeSettings,
|
||||||
|
ManageDiscoverProfile,
|
||||||
|
ViewBilling,
|
||||||
ManageBilling,
|
ManageBilling,
|
||||||
ManageBranches,
|
ViewBranches,
|
||||||
|
CreateBranch,
|
||||||
// Management (Owner + Manager)
|
EditBranch,
|
||||||
ManageStaff,
|
DeleteBranch,
|
||||||
ManageMenu,
|
ManageRoles,
|
||||||
ManageInventory,
|
ViewPrintSettings,
|
||||||
ManageExpenses,
|
|
||||||
ManageTaxes,
|
|
||||||
ManageCoupons,
|
|
||||||
ManageReservations,
|
|
||||||
ManageTables,
|
|
||||||
ViewReports,
|
|
||||||
ReviewLeave,
|
|
||||||
ManageSalaries,
|
|
||||||
ManagePrintSettings,
|
ManagePrintSettings,
|
||||||
|
|
||||||
// Front-of-house operations
|
// ── Taxes ─────────────────────────────────────────────────────────────────
|
||||||
|
ViewTaxes,
|
||||||
|
CreateTax,
|
||||||
|
EditTax,
|
||||||
|
DeleteTax,
|
||||||
|
|
||||||
|
// ── Staff & HR ──────────────────────────────────────────────────────────────
|
||||||
|
ViewStaff,
|
||||||
|
CreateStaff,
|
||||||
|
EditStaff,
|
||||||
|
DeleteStaff,
|
||||||
|
/// <summary>Assign per-branch roles / org structure (distinct from editing a record).</summary>
|
||||||
|
ManageStaff,
|
||||||
|
ManageStaffCredentials,
|
||||||
|
ViewAttendance,
|
||||||
|
ManageAttendance,
|
||||||
|
ViewSchedules,
|
||||||
|
ManageSchedules,
|
||||||
|
ViewLeave,
|
||||||
|
ReviewLeave,
|
||||||
|
ViewSalaries,
|
||||||
|
ManageSalaries,
|
||||||
|
|
||||||
|
// ── Menu ──────────────────────────────────────────────────────────────────
|
||||||
|
ViewMenu,
|
||||||
|
CreateMenuItem,
|
||||||
|
EditMenuItem,
|
||||||
|
DeleteMenuItem,
|
||||||
|
|
||||||
|
// ── Inventory ───────────────────────────────────────────────────────────────
|
||||||
|
ViewInventory,
|
||||||
|
CreateInventory,
|
||||||
|
EditInventory,
|
||||||
|
DeleteInventory,
|
||||||
|
|
||||||
|
// ── Tables ──────────────────────────────────────────────────────────────────
|
||||||
|
ViewTables,
|
||||||
|
ManageTables,
|
||||||
|
|
||||||
|
// ── Reservations ──────────────────────────────────────────────────────────
|
||||||
|
ViewReservations,
|
||||||
|
CreateReservation,
|
||||||
|
EditReservation,
|
||||||
|
DeleteReservation,
|
||||||
|
|
||||||
|
// ── Orders & POS ──────────────────────────────────────────────────────────
|
||||||
|
ViewOrders,
|
||||||
ProcessOrders,
|
ProcessOrders,
|
||||||
|
EditOrder,
|
||||||
|
VoidOrder,
|
||||||
|
RefundOrder,
|
||||||
|
ApplyDiscount,
|
||||||
|
CompOrder,
|
||||||
HandlePayments,
|
HandlePayments,
|
||||||
|
UpdateOrderStatus,
|
||||||
|
|
||||||
|
// ── Register / cash ──────────────────────────────────────────────────────
|
||||||
OperateRegister,
|
OperateRegister,
|
||||||
|
OpenCashDrawer,
|
||||||
|
|
||||||
|
// ── Queue ─────────────────────────────────────────────────────────────────
|
||||||
|
ViewQueue,
|
||||||
ManageQueue,
|
ManageQueue,
|
||||||
|
|
||||||
// Kitchen
|
// ── Kitchen ───────────────────────────────────────────────────────────────
|
||||||
ViewKitchen,
|
ViewKitchen,
|
||||||
|
ManageKitchenStations,
|
||||||
|
|
||||||
// Delivery
|
// ── Delivery ──────────────────────────────────────────────────────────────
|
||||||
|
ViewDelivery,
|
||||||
HandleDelivery,
|
HandleDelivery,
|
||||||
|
AssignDelivery,
|
||||||
|
|
||||||
|
// ── Customers / CRM ───────────────────────────────────────────────────────
|
||||||
|
ViewCustomers,
|
||||||
|
CreateCustomer,
|
||||||
|
EditCustomer,
|
||||||
|
DeleteCustomer,
|
||||||
|
|
||||||
|
// ── Coupons ───────────────────────────────────────────────────────────────
|
||||||
|
ViewCoupons,
|
||||||
|
CreateCoupon,
|
||||||
|
EditCoupon,
|
||||||
|
DeleteCoupon,
|
||||||
|
|
||||||
|
// ── SMS / marketing ──────────────────────────────────────────────────────
|
||||||
|
ViewSms,
|
||||||
|
SendSms,
|
||||||
|
ManageSmsSettings,
|
||||||
|
|
||||||
|
// ── Reviews ───────────────────────────────────────────────────────────────
|
||||||
|
ViewReviews,
|
||||||
|
ManageReviews,
|
||||||
|
|
||||||
|
// ── Reports & finance ─────────────────────────────────────────────────────
|
||||||
|
ViewReports,
|
||||||
|
ExportReports,
|
||||||
|
ViewAuditLog,
|
||||||
|
ViewFinancials,
|
||||||
|
ManageFinancials,
|
||||||
|
|
||||||
|
// ── Expenses ──────────────────────────────────────────────────────────────
|
||||||
|
ViewExpenses,
|
||||||
|
CreateExpense,
|
||||||
|
EditExpense,
|
||||||
|
DeleteExpense,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,66 @@
|
|||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
|
using static Meezi.Core.Authorization.Permission;
|
||||||
|
|
||||||
namespace Meezi.Core.Authorization;
|
namespace Meezi.Core.Authorization;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The authoritative role→capability matrix. Change what a role can do here and
|
/// The authoritative role→capability matrix. Change what a base role can do here
|
||||||
/// every controller that calls <c>EnsurePermission</c> updates automatically.
|
/// and every controller that calls <c>EnsurePermission</c> updates automatically.
|
||||||
|
/// Owners customise further with custom roles (which override this matrix entirely).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class RolePermissions
|
public static class RolePermissions
|
||||||
{
|
{
|
||||||
|
/// <summary>Capabilities reserved to the Owner — the rest is the Manager baseline.</summary>
|
||||||
|
private static readonly HashSet<Permission> OwnerOnly = new()
|
||||||
|
{
|
||||||
|
ManageCafeSettings,
|
||||||
|
ManageDiscoverProfile,
|
||||||
|
ViewBilling,
|
||||||
|
ManageBilling,
|
||||||
|
CreateBranch,
|
||||||
|
EditBranch,
|
||||||
|
DeleteBranch,
|
||||||
|
ManageRoles,
|
||||||
|
};
|
||||||
|
|
||||||
private static readonly IReadOnlyDictionary<EmployeeRole, HashSet<Permission>> Matrix =
|
private static readonly IReadOnlyDictionary<EmployeeRole, HashSet<Permission>> Matrix =
|
||||||
new Dictionary<EmployeeRole, HashSet<Permission>>
|
new Dictionary<EmployeeRole, HashSet<Permission>>
|
||||||
{
|
{
|
||||||
[EmployeeRole.Owner] = AllPermissions(),
|
[EmployeeRole.Owner] = AllPermissions(),
|
||||||
|
|
||||||
[EmployeeRole.Manager] = new()
|
// Manager runs the café day to day: everything except the owner-only
|
||||||
{
|
// governance (billing, branches, café identity, role definitions).
|
||||||
Permission.ManageStaff,
|
[EmployeeRole.Manager] = AllExcept(OwnerOnly),
|
||||||
Permission.ManageMenu,
|
|
||||||
Permission.ManageInventory,
|
|
||||||
Permission.ManageExpenses,
|
|
||||||
Permission.ManageTaxes,
|
|
||||||
Permission.ManageCoupons,
|
|
||||||
Permission.ManageReservations,
|
|
||||||
Permission.ManageTables,
|
|
||||||
Permission.ViewReports,
|
|
||||||
Permission.ReviewLeave,
|
|
||||||
Permission.ManageSalaries,
|
|
||||||
Permission.ManagePrintSettings,
|
|
||||||
Permission.ProcessOrders,
|
|
||||||
Permission.HandlePayments,
|
|
||||||
Permission.OperateRegister,
|
|
||||||
Permission.ManageQueue,
|
|
||||||
Permission.ViewKitchen,
|
|
||||||
Permission.HandleDelivery,
|
|
||||||
},
|
|
||||||
|
|
||||||
[EmployeeRole.Cashier] = new()
|
[EmployeeRole.Cashier] = new()
|
||||||
{
|
{
|
||||||
Permission.ProcessOrders,
|
ViewOrders, ProcessOrders, EditOrder, HandlePayments, UpdateOrderStatus,
|
||||||
Permission.HandlePayments,
|
OperateRegister, OpenCashDrawer,
|
||||||
Permission.OperateRegister,
|
ViewQueue, ManageQueue,
|
||||||
Permission.ManageQueue,
|
ViewTables,
|
||||||
Permission.ManageReservations,
|
ViewReservations, CreateReservation, EditReservation,
|
||||||
|
ViewMenu,
|
||||||
|
ViewCustomers, CreateCustomer,
|
||||||
|
ViewCoupons,
|
||||||
},
|
},
|
||||||
|
|
||||||
[EmployeeRole.Waiter] = new()
|
[EmployeeRole.Waiter] = new()
|
||||||
{
|
{
|
||||||
Permission.ProcessOrders,
|
ViewOrders, ProcessOrders, EditOrder, UpdateOrderStatus,
|
||||||
Permission.ManageReservations,
|
ViewTables,
|
||||||
Permission.ManageQueue,
|
ViewMenu,
|
||||||
|
ViewReservations, CreateReservation, EditReservation,
|
||||||
|
ViewQueue, ManageQueue,
|
||||||
},
|
},
|
||||||
|
|
||||||
[EmployeeRole.Chef] = new()
|
[EmployeeRole.Chef] = new()
|
||||||
{
|
{
|
||||||
Permission.ViewKitchen,
|
ViewKitchen, UpdateOrderStatus, ViewOrders, ViewMenu,
|
||||||
},
|
},
|
||||||
|
|
||||||
[EmployeeRole.Delivery] = new()
|
[EmployeeRole.Delivery] = new()
|
||||||
{
|
{
|
||||||
Permission.HandleDelivery,
|
ViewDelivery, HandleDelivery, ViewOrders,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,4 +75,7 @@ public static class RolePermissions
|
|||||||
|
|
||||||
private static HashSet<Permission> AllPermissions() =>
|
private static HashSet<Permission> AllPermissions() =>
|
||||||
new(Enum.GetValues<Permission>());
|
new(Enum.GetValues<Permission>());
|
||||||
|
|
||||||
|
private static HashSet<Permission> AllExcept(HashSet<Permission> excluded) =>
|
||||||
|
new(Enum.GetValues<Permission>().Where(p => !excluded.Contains(p)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ public class Branch : TenantEntity
|
|||||||
public int? ReceiptPrinterPort { get; set; }
|
public int? ReceiptPrinterPort { get; set; }
|
||||||
public string? KitchenPrinterIp { get; set; }
|
public string? KitchenPrinterIp { get; set; }
|
||||||
public int? KitchenPrinterPort { get; set; }
|
public int? KitchenPrinterPort { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional <see cref="PrintDevice"/> to route through a local print agent
|
||||||
|
/// (preferred over the raw IP when its agent is online). Cloud-hosted cafés use this.</summary>
|
||||||
|
public string? ReceiptPrintDeviceId { get; set; }
|
||||||
|
public string? KitchenPrintDeviceId { get; set; }
|
||||||
public int PaperWidthMm { get; set; } = 80;
|
public int PaperWidthMm { get; set; } = 80;
|
||||||
public bool AutoCutEnabled { get; set; } = true;
|
public bool AutoCutEnabled { get; set; } = true;
|
||||||
public string? ReceiptHeader { get; set; }
|
public string? ReceiptHeader { get; set; }
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ public class KitchenStation : TenantEntity
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string? PrinterIp { get; set; }
|
public string? PrinterIp { get; set; }
|
||||||
public int PrinterPort { get; set; } = 9100;
|
public int PrinterPort { get; set; } = 9100;
|
||||||
|
|
||||||
|
/// <summary>Optional <see cref="PrintDevice"/> routed through a local print agent
|
||||||
|
/// (preferred over <see cref="PrinterIp"/> when its agent is online).</summary>
|
||||||
|
public string? PrintDeviceId { get; set; }
|
||||||
|
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
public Cafe Cafe { get; set; } = null!;
|
public Cafe Cafe { get; set; } = null!;
|
||||||
public Branch? Branch { get; set; }
|
public Branch? Branch { get; set; }
|
||||||
public ICollection<MenuCategory> Categories { get; set; } = [];
|
public ICollection<MenuCategory> Categories { get; set; } = [];
|
||||||
|
public ICollection<MenuItem> MenuItems { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ public class MenuItem : TenantEntity
|
|||||||
/// <summary>GLB/GLTF 3D model for QR menu interactive view (poster = ImageUrl).</summary>
|
/// <summary>GLB/GLTF 3D model for QR menu interactive view (poster = ImageUrl).</summary>
|
||||||
public string? Model3dUrl { get; set; }
|
public string? Model3dUrl { get; set; }
|
||||||
public bool IsAvailable { get; set; } = true;
|
public bool IsAvailable { get; set; } = true;
|
||||||
|
/// <summary>Optional per-item print station (cold bar, kitchen, barista …).
|
||||||
|
/// Overrides the item's category station when set.</summary>
|
||||||
|
public string? KitchenStationId { get; set; }
|
||||||
|
|
||||||
public Cafe Cafe { get; set; } = null!;
|
public Cafe Cafe { get; set; } = null!;
|
||||||
public MenuCategory Category { get; set; } = null!;
|
public MenuCategory Category { get; set; } = null!;
|
||||||
|
public KitchenStation? KitchenStation { get; set; }
|
||||||
public ICollection<OrderItem> OrderItems { get; set; } = [];
|
public ICollection<OrderItem> OrderItems { get; set; } = [];
|
||||||
public ICollection<BranchMenuItemOverride> BranchOverrides { get; set; } = [];
|
public ICollection<BranchMenuItemOverride> BranchOverrides { get; set; } = [];
|
||||||
public ICollection<MenuItemIngredient> RecipeIngredients { get; set; } = [];
|
public ICollection<MenuItemIngredient> RecipeIngredients { get; set; } = [];
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace Meezi.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A local print bridge installed on a café PC. It connects outbound to the cloud
|
||||||
|
/// over SignalR (authenticated by its token), reports the printers it can see, and
|
||||||
|
/// relays print jobs to them — so the cloud can reach LAN/USB printers it could
|
||||||
|
/// never connect to directly.
|
||||||
|
/// </summary>
|
||||||
|
public class PrintAgent : TenantEntity
|
||||||
|
{
|
||||||
|
public string? BranchId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Short one-time code shown in the dashboard; the agent exchanges it for a token.</summary>
|
||||||
|
public string? PairingCode { get; set; }
|
||||||
|
public DateTime? PairingCodeExpiresAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>SHA-256 (hex) of the long-lived agent token. Null until the agent is paired.</summary>
|
||||||
|
public string? TokenHash { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Last time the agent connected or sent a heartbeat (UTC).</summary>
|
||||||
|
public DateTime? LastSeenAt { get; set; }
|
||||||
|
|
||||||
|
public bool Revoked { get; set; }
|
||||||
|
|
||||||
|
public Cafe Cafe { get; set; } = null!;
|
||||||
|
public Branch? Branch { get; set; }
|
||||||
|
public ICollection<PrintDevice> Devices { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Meezi.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>A printer discovered and reported by a <see cref="PrintAgent"/>.</summary>
|
||||||
|
public class PrintDevice : TenantEntity
|
||||||
|
{
|
||||||
|
public string AgentId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Stable identifier the agent uses to print (OS printer name, or "ip:port").</summary>
|
||||||
|
public string SystemName { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>"usb" | "network" | "other".</summary>
|
||||||
|
public string Kind { get; set; } = "other";
|
||||||
|
|
||||||
|
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public PrintAgent Agent { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -4,7 +4,11 @@ public enum PaymentProvider
|
|||||||
{
|
{
|
||||||
ZarinPal = 0,
|
ZarinPal = 0,
|
||||||
Tara = 1,
|
Tara = 1,
|
||||||
SnappPay = 2
|
SnappPay = 2,
|
||||||
|
// Appended (stored as int) so existing rows keep their meaning — no migration needed.
|
||||||
|
FlatPay = 3,
|
||||||
|
/// <summary>A free subscription granted by a platform admin (no money changed hands).</summary>
|
||||||
|
Manual = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class PaymentProviderIds
|
public static class PaymentProviderIds
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<CafeReviewPhoto> CafeReviewPhotos => Set<CafeReviewPhoto>();
|
public DbSet<CafeReviewPhoto> CafeReviewPhotos => Set<CafeReviewPhoto>();
|
||||||
public DbSet<ConsumerAccount> ConsumerAccounts => Set<ConsumerAccount>();
|
public DbSet<ConsumerAccount> ConsumerAccounts => Set<ConsumerAccount>();
|
||||||
public DbSet<KitchenStation> KitchenStations => Set<KitchenStation>();
|
public DbSet<KitchenStation> KitchenStations => Set<KitchenStation>();
|
||||||
|
public DbSet<PrintAgent> PrintAgents => Set<PrintAgent>();
|
||||||
|
public DbSet<PrintDevice> PrintDevices => Set<PrintDevice>();
|
||||||
public DbSet<SubscriptionPayment> SubscriptionPayments => Set<SubscriptionPayment>();
|
public DbSet<SubscriptionPayment> SubscriptionPayments => Set<SubscriptionPayment>();
|
||||||
public DbSet<Ingredient> Ingredients => Set<Ingredient>();
|
public DbSet<Ingredient> Ingredients => Set<Ingredient>();
|
||||||
public DbSet<MenuItemIngredient> MenuItemIngredients => Set<MenuItemIngredient>();
|
public DbSet<MenuItemIngredient> MenuItemIngredients => Set<MenuItemIngredient>();
|
||||||
@@ -271,6 +273,7 @@ public class AppDbContext : DbContext
|
|||||||
e.Property(x => x.Price).HasPrecision(18, 2);
|
e.Property(x => x.Price).HasPrecision(18, 2);
|
||||||
e.HasOne(x => x.Cafe).WithMany(c => c.MenuItems).HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne(x => x.Cafe).WithMany(c => c.MenuItems).HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
|
||||||
e.HasOne(x => x.Category).WithMany(c => c.MenuItems).HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.Cascade);
|
e.HasOne(x => x.Category).WithMany(c => c.MenuItems).HasForeignKey(x => x.CategoryId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne(x => x.KitchenStation).WithMany(s => s.MenuItems).HasForeignKey(x => x.KitchenStationId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -458,6 +461,32 @@ public class AppDbContext : DbContext
|
|||||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<PrintAgent>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.Name).HasMaxLength(120).IsRequired();
|
||||||
|
e.Property(x => x.PairingCode).HasMaxLength(16);
|
||||||
|
e.Property(x => x.TokenHash).HasMaxLength(128);
|
||||||
|
e.HasIndex(x => x.TokenHash);
|
||||||
|
e.HasIndex(x => x.PairingCode);
|
||||||
|
e.HasIndex(x => x.CafeId);
|
||||||
|
e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
e.HasMany(x => x.Devices).WithOne(d => d.Agent).HasForeignKey(d => d.AgentId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
// Café-wide agents (BranchId null) stay visible inside any branch scope.
|
||||||
|
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId || x.BranchId == null));
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<PrintDevice>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.SystemName).HasMaxLength(256).IsRequired();
|
||||||
|
e.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
|
||||||
|
e.Property(x => x.Kind).HasMaxLength(20);
|
||||||
|
e.HasIndex(x => new { x.AgentId, x.SystemName }).IsUnique();
|
||||||
|
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<SubscriptionPayment>(e =>
|
modelBuilder.Entity<SubscriptionPayment>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
|
|||||||
Generated
+3511
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMenuItemKitchenStation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "KitchenStationId",
|
||||||
|
table: "MenuItems",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_MenuItems_KitchenStationId",
|
||||||
|
table: "MenuItems",
|
||||||
|
column: "KitchenStationId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_MenuItems_KitchenStations_KitchenStationId",
|
||||||
|
table: "MenuItems",
|
||||||
|
column: "KitchenStationId",
|
||||||
|
principalTable: "KitchenStations",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_MenuItems_KitchenStations_KitchenStationId",
|
||||||
|
table: "MenuItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_MenuItems_KitchenStationId",
|
||||||
|
table: "MenuItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "KitchenStationId",
|
||||||
|
table: "MenuItems");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3643
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPrintAgents : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PrintAgents",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
BranchId = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||||
|
PairingCode = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: true),
|
||||||
|
PairingCodeExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
TokenHash = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
LastSeenAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
Revoked = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
CafeId = table.Column<string>(type: "text", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PrintAgents", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PrintAgents_Branches_BranchId",
|
||||||
|
column: x => x.BranchId,
|
||||||
|
principalTable: "Branches",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PrintAgents_Cafes_CafeId",
|
||||||
|
column: x => x.CafeId,
|
||||||
|
principalTable: "Cafes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PrintDevices",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
AgentId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
SystemName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
DisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
Kind = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
LastSeenAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
CafeId = table.Column<string>(type: "text", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PrintDevices", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PrintDevices_PrintAgents_AgentId",
|
||||||
|
column: x => x.AgentId,
|
||||||
|
principalTable: "PrintAgents",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PrintAgents_BranchId",
|
||||||
|
table: "PrintAgents",
|
||||||
|
column: "BranchId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PrintAgents_CafeId",
|
||||||
|
table: "PrintAgents",
|
||||||
|
column: "CafeId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PrintAgents_PairingCode",
|
||||||
|
table: "PrintAgents",
|
||||||
|
column: "PairingCode");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PrintAgents_TokenHash",
|
||||||
|
table: "PrintAgents",
|
||||||
|
column: "TokenHash");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PrintDevices_AgentId_SystemName",
|
||||||
|
table: "PrintDevices",
|
||||||
|
columns: new[] { "AgentId", "SystemName" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PrintDevices");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PrintAgents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3652
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPrintDeviceRouting : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PrintDeviceId",
|
||||||
|
table: "KitchenStations",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "KitchenPrintDeviceId",
|
||||||
|
table: "Branches",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ReceiptPrintDeviceId",
|
||||||
|
table: "Branches",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PrintDeviceId",
|
||||||
|
table: "KitchenStations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "KitchenPrintDeviceId",
|
||||||
|
table: "Branches");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReceiptPrintDeviceId",
|
||||||
|
table: "Branches");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,6 +155,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("KitchenPrintDeviceId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("KitchenPrinterIp")
|
b.Property<string>("KitchenPrinterIp")
|
||||||
.HasMaxLength(45)
|
.HasMaxLength(45)
|
||||||
.HasColumnType("character varying(45)");
|
.HasColumnType("character varying(45)");
|
||||||
@@ -192,6 +195,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("ReceiptPrintDeviceId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("ReceiptPrinterIp")
|
b.Property<string>("ReceiptPrinterIp")
|
||||||
.HasMaxLength(45)
|
.HasMaxLength(45)
|
||||||
.HasColumnType("character varying(45)");
|
.HasColumnType("character varying(45)");
|
||||||
@@ -707,6 +713,46 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("Coupons");
|
b.ToTable("Coupons");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CafeId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Color")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PermissionsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CafeId");
|
||||||
|
|
||||||
|
b.ToTable("CustomRoles");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -928,46 +974,6 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("DemoRequests");
|
b.ToTable("DemoRequests");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("CafeId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Color")
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("DeletedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnType("character varying(500)");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("PermissionsJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(2000)
|
|
||||||
.HasColumnType("character varying(2000)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("CafeId");
|
|
||||||
|
|
||||||
b.ToTable("CustomRoles");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -1313,6 +1319,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)");
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PrintDeviceId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("PrinterIp")
|
b.Property<string>("PrinterIp")
|
||||||
.HasMaxLength(45)
|
.HasMaxLength(45)
|
||||||
.HasColumnType("character varying(45)");
|
.HasColumnType("character varying(45)");
|
||||||
@@ -1516,6 +1525,9 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("IsAvailable")
|
b.Property<bool>("IsAvailable")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("KitchenStationId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Model3dUrl")
|
b.Property<string>("Model3dUrl")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
@@ -1543,6 +1555,8 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CategoryId");
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("KitchenStationId");
|
||||||
|
|
||||||
b.ToTable("MenuItems");
|
b.ToTable("MenuItems");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1930,6 +1944,104 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("PlatformSettings");
|
b.ToTable("PlatformSettings");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("BranchId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CafeId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastSeenAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("PairingCode")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PairingCodeExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("Revoked")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BranchId");
|
||||||
|
|
||||||
|
b.HasIndex("CafeId");
|
||||||
|
|
||||||
|
b.HasIndex("PairingCode");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.ToTable("PrintAgents");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.PrintDevice", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("AgentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("CafeId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastSeenAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("SystemName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AgentId", "SystemName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("PrintDevices");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.PushDevice", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.PushDevice", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -2824,6 +2936,17 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("Cafe");
|
b.Navigation("Cafe");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CafeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Cafe");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
|
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
|
||||||
@@ -2857,17 +2980,6 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("Cafe");
|
b.Navigation("Cafe");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("CafeId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Cafe");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
|
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
|
||||||
@@ -3031,9 +3143,16 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Meezi.Core.Entities.KitchenStation", "KitchenStation")
|
||||||
|
.WithMany("MenuItems")
|
||||||
|
.HasForeignKey("KitchenStationId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.Navigation("Cafe");
|
b.Navigation("Cafe");
|
||||||
|
|
||||||
b.Navigation("Category");
|
b.Navigation("Category");
|
||||||
|
|
||||||
|
b.Navigation("KitchenStation");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.MenuItemIngredient", b =>
|
||||||
@@ -3138,6 +3257,35 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("Order");
|
b.Navigation("Order");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("BranchId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CafeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Branch");
|
||||||
|
|
||||||
|
b.Navigation("Cafe");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.PrintDevice", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Meezi.Core.Entities.PrintAgent", "Agent")
|
||||||
|
.WithMany("Devices")
|
||||||
|
.HasForeignKey("AgentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Agent");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
|
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
|
||||||
@@ -3401,16 +3549,16 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("Orders");
|
b.Navigation("Orders");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Orders");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Employees");
|
b.Navigation("Employees");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.Customer", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Orders");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.Employee", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Attendances");
|
b.Navigation("Attendances");
|
||||||
@@ -3436,6 +3584,8 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.KitchenStation", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Categories");
|
b.Navigation("Categories");
|
||||||
|
|
||||||
|
b.Navigation("MenuItems");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b =>
|
||||||
@@ -3459,6 +3609,11 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("Payments");
|
b.Navigation("Payments");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Devices");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.Shift", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.Shift", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Transactions");
|
b.Navigation("Transactions");
|
||||||
|
|||||||
@@ -1159,6 +1159,15 @@
|
|||||||
"save": "حفظ",
|
"save": "حفظ",
|
||||||
"saved": "تم الحفظ",
|
"saved": "تم الحفظ",
|
||||||
"loading": "جاري التحميل..."
|
"loading": "جاري التحميل..."
|
||||||
|
},
|
||||||
|
"grant": {
|
||||||
|
"title": "منح اشتراك مجاني",
|
||||||
|
"plan": "الباقة",
|
||||||
|
"months": "عدد الأشهر",
|
||||||
|
"submit": "منح",
|
||||||
|
"granted": "تم منح الاشتراك",
|
||||||
|
"failed": "تعذّر منح الاشتراك",
|
||||||
|
"currentExpiry": "انتهاء الصلاحية الحالي"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
|
|||||||
@@ -1152,6 +1152,15 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"loading": "Loading..."
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"grant": {
|
||||||
|
"title": "Grant free subscription",
|
||||||
|
"plan": "Plan",
|
||||||
|
"months": "Months",
|
||||||
|
"submit": "Grant",
|
||||||
|
"granted": "Subscription granted",
|
||||||
|
"failed": "Could not grant subscription",
|
||||||
|
"currentExpiry": "Current expiry"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
|
|||||||
@@ -1152,6 +1152,15 @@
|
|||||||
"save": "ذخیره",
|
"save": "ذخیره",
|
||||||
"saved": "ذخیره شد",
|
"saved": "ذخیره شد",
|
||||||
"loading": "در حال بارگذاری..."
|
"loading": "در حال بارگذاری..."
|
||||||
|
},
|
||||||
|
"grant": {
|
||||||
|
"title": "افزودن اشتراک رایگان",
|
||||||
|
"plan": "پلن",
|
||||||
|
"months": "تعداد ماه",
|
||||||
|
"submit": "اعطا",
|
||||||
|
"granted": "اشتراک اعطا شد",
|
||||||
|
"failed": "اعطای اشتراک ناموفق بود",
|
||||||
|
"currentExpiry": "انقضای فعلی"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
|
|||||||
@@ -493,6 +493,7 @@ export function AdminCafesScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<GrantSubscriptionPanel cafe={c} />
|
||||||
<RecoveryKeyPanel cafe={c} />
|
<RecoveryKeyPanel cafe={c} />
|
||||||
{profileCafeId === c.id ? (
|
{profileCafeId === c.id ? (
|
||||||
<CafeDiscoverProfilePanel cafeId={c.id} mode="admin" compact />
|
<CafeDiscoverProfilePanel cafeId={c.id} mode="admin" compact />
|
||||||
@@ -504,6 +505,70 @@ export function AdminCafesScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gift a café a free subscription: pick a plan + number of months and apply.
|
||||||
|
* Months are appended to any coverage the café already has. */
|
||||||
|
function GrantSubscriptionPanel({ cafe }: { cafe: AdminCafe }) {
|
||||||
|
const t = useTranslations("admin.cafes.grant");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [tier, setTier] = useState("Pro");
|
||||||
|
const [months, setMonths] = useState(1);
|
||||||
|
|
||||||
|
const TIERS = ["Starter", "Pro", "Business", "Enterprise"];
|
||||||
|
|
||||||
|
const grant = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
adminPost(`/api/admin/cafes/${cafe.id}/grant-subscription`, { planTier: tier, months }),
|
||||||
|
onSuccess: () => {
|
||||||
|
notify.success(t("granted"));
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin", "cafes"] });
|
||||||
|
},
|
||||||
|
onError: () => notify.error(t("failed")),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-lg border border-border/70 bg-muted/20 p-3 text-sm">
|
||||||
|
<p className="font-medium">{t("title")}</p>
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">{t("plan")}</span>
|
||||||
|
<select
|
||||||
|
value={tier}
|
||||||
|
onChange={(e) => setTier(e.target.value)}
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||||
|
>
|
||||||
|
{TIERS.map((x) => (
|
||||||
|
<option key={x} value={x}>
|
||||||
|
{x}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">{t("months")}</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={120}
|
||||||
|
value={months}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMonths(Math.max(1, Math.min(120, Number(e.target.value) || 1)))
|
||||||
|
}
|
||||||
|
className="h-9 w-24"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button size="sm" disabled={grant.isPending} onClick={() => grant.mutate()}>
|
||||||
|
{t("submit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{cafe.planExpiresAt ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("currentExpiry")}: {new Date(cafe.planExpiresAt).toLocaleDateString("fa-IR")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate / revoke a café's permanent recovery key. The raw key is returned
|
* Generate / revoke a café's permanent recovery key. The raw key is returned
|
||||||
* once on generate — shown here for copy, never retrievable again.
|
* once on generate — shown here for copy, never retrievable again.
|
||||||
|
|||||||
+210
-28
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"save": "حفظ",
|
"save": "حفظ",
|
||||||
|
"close": "إغلاق",
|
||||||
"cancel": "إلغاء",
|
"cancel": "إلغاء",
|
||||||
"confirm": "تأكيد",
|
"confirm": "تأكيد",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"auth": {
|
"auth": {
|
||||||
"title": "تسجيل الدخول إلى ميزي",
|
"title": "تسجيل الدخول إلى ميزي",
|
||||||
"subtitle": "سيتم إرسال رمز التحقق إلى هاتفك",
|
"subtitle": "سيتم إرسال رمز التحقق إلى هاتفك",
|
||||||
|
"redirecting": "مسجّل الدخول بالفعل — يتم التحويل…",
|
||||||
"phone": "رقم الجوال",
|
"phone": "رقم الجوال",
|
||||||
"phonePlaceholder": "٠٩١٢١٢٣٤٥٦٧",
|
"phonePlaceholder": "٠٩١٢١٢٣٤٥٦٧",
|
||||||
"sendOtp": "إرسال الرمز",
|
"sendOtp": "إرسال الرمز",
|
||||||
@@ -116,6 +118,7 @@
|
|||||||
"menu": "القائمة",
|
"menu": "القائمة",
|
||||||
"crm": "العملاء",
|
"crm": "العملاء",
|
||||||
"coupons": "القسائم",
|
"coupons": "القسائم",
|
||||||
|
"orders": "الطلبات",
|
||||||
"inventory": "المخزون",
|
"inventory": "المخزون",
|
||||||
"hr": "الموارد البشرية",
|
"hr": "الموارد البشرية",
|
||||||
"reports": "التقارير",
|
"reports": "التقارير",
|
||||||
@@ -329,7 +332,49 @@
|
|||||||
"configurePrinters": "فتح إعدادات الطابعة",
|
"configurePrinters": "فتح إعدادات الطابعة",
|
||||||
"posDeviceSection": "جهاز نقطة البيع (بطاقة)",
|
"posDeviceSection": "جهاز نقطة البيع (بطاقة)",
|
||||||
"posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.",
|
"posDeviceHint": "عند الدفع بالبطاقة، يُرسل المبلغ عبر HTTP (POST /pay) إلى الجهاز على الشبكة المحلية.",
|
||||||
"posDeviceIp": "عنوان IP لجهاز نقطة البيع"
|
"posDeviceIp": "عنوان IP لجهاز نقطة البيع",
|
||||||
|
"detect": "كشف تلقائي",
|
||||||
|
"detecting": "جارٍ فحص الشبكة…",
|
||||||
|
"detectNone": "لم يُعثر على أجهزة في الشبكة",
|
||||||
|
"detectOffline": "يجب أن يكون خادم الطباعة متصلاً للكشف التلقائي",
|
||||||
|
"detectHint": "يفحص خادم الطباعة شبكتك المحلية للعثور على الجهاز.",
|
||||||
|
"testSent": "تم إرسال الاختبار إلى الطابعة.",
|
||||||
|
"sent": "تم الإرسال إلى الطابعة.",
|
||||||
|
"noStationItems": "لا توجد أصناف لهذه المحطة في هذا الطلب.",
|
||||||
|
"printFailed": "فشلت الطباعة.",
|
||||||
|
"stations": {
|
||||||
|
"title": "محطات طباعة المطبخ والبار",
|
||||||
|
"subtitle": "امنح كل قسم تحضير طابعته الخاصة ووجّه فئات القائمة إليها.",
|
||||||
|
"help": "أنشئ محطة (مثل المطبخ أو البار) بطابعتها الخاصة، ثم من «القائمة» اختر محطة الطباعة لكل فئة — الطعام ← المطبخ، المشروبات ← البار. أصناف الفئات بدون محطة تُطبع على طابعة مطبخ الفرع. أما فاتورة العميل فتُطبع دائمًا على طابعة الفواتير.",
|
||||||
|
"add": "إضافة محطة",
|
||||||
|
"name": "اسم المحطة",
|
||||||
|
"namePlaceholder": "مثل المطبخ، البار",
|
||||||
|
"printerIp": "IP الطابعة",
|
||||||
|
"noPrinter": "بدون طابعة — تُستخدم طابعة المطبخ",
|
||||||
|
"categoryCount": "{count} فئات",
|
||||||
|
"test": "اختبار",
|
||||||
|
"empty": "لا توجد محطات بعد. أضف «المطبخ» و«البار» لطباعة أصنافهما بشكل منفصل.",
|
||||||
|
"deleteConfirm": "حذف المحطة «{name}»؟ ستعود فئاتها إلى طابعة المطبخ.",
|
||||||
|
"saveError": "تعذّر حفظ المحطة."
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"title": "خوادم الطباعة (اكتشاف تلقائي)",
|
||||||
|
"hint": "ثبّت وكيل طباعة Meezi على جهاز الكاشير لاكتشاف طابعات USB والشبكة تلقائياً والطباعة عبره.",
|
||||||
|
"add": "إضافة خادم طباعة",
|
||||||
|
"pairingTitle": "أدخل هذا الرمز في وكيل الطباعة على جهاز الكاشير:",
|
||||||
|
"pairingSteps": "ثبّت وشغّل وكيل طباعة Meezi على الجهاز المتصل بالطابعات ثم أدخل هذا الرمز. صالح لمدة 15 دقيقة.",
|
||||||
|
"empty": "لا يوجد خادم طباعة متصل بعد.",
|
||||||
|
"online": "متصل",
|
||||||
|
"offline": "غير متصل",
|
||||||
|
"noDevices": "جارٍ اكتشاف الطابعات…",
|
||||||
|
"test": "اختبار",
|
||||||
|
"receiptVia": "طابعة الإيصال (عبر الخادم)",
|
||||||
|
"kitchenVia": "طابعة المطبخ (عبر الخادم)",
|
||||||
|
"viaServer": "الطابعة (عبر الخادم)",
|
||||||
|
"useIpInstead": "— استخدام IP يدوي —",
|
||||||
|
"revokeConfirm": "إزالة خادم الطباعة «{name}»؟ لن يتمكن من الطباعة بعد ذلك.",
|
||||||
|
"codeError": "تعذّر إنشاء الرمز."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"receipt": {
|
"receipt": {
|
||||||
"table": "الطاولة",
|
"table": "الطاولة",
|
||||||
@@ -416,7 +461,8 @@
|
|||||||
"payroll": "الرواتب",
|
"payroll": "الرواتب",
|
||||||
"access": "صلاحيات الفروع",
|
"access": "صلاحيات الفروع",
|
||||||
"credentials": "بيانات الدخول",
|
"credentials": "بيانات الدخول",
|
||||||
"team": "الموظفون"
|
"team": "الموظفون",
|
||||||
|
"roles": "الأدوار والصلاحيات"
|
||||||
},
|
},
|
||||||
"myAttendance": "حضوري",
|
"myAttendance": "حضوري",
|
||||||
"clockIn": "تسجيل دخول",
|
"clockIn": "تسجيل دخول",
|
||||||
@@ -445,6 +491,9 @@
|
|||||||
"addEmployee": "إضافة موظف",
|
"addEmployee": "إضافة موظف",
|
||||||
"noEmployees": "لا يوجد موظفون بعد.",
|
"noEmployees": "لا يوجد موظفون بعد.",
|
||||||
"employeeCreated": "تمت إضافة الموظف",
|
"employeeCreated": "تمت إضافة الموظف",
|
||||||
|
"employeeDetails": "تفاصيل الموظف",
|
||||||
|
"employeeNotFound": "هذا المستخدم لم يعد نشطًا.",
|
||||||
|
"openInHr": "فتح في الموارد البشرية",
|
||||||
"save": "حفظ",
|
"save": "حفظ",
|
||||||
"cancel": "إلغاء",
|
"cancel": "إلغاء",
|
||||||
"fields": {
|
"fields": {
|
||||||
@@ -631,6 +680,7 @@
|
|||||||
"colSummary": "الوصف",
|
"colSummary": "الوصف",
|
||||||
"details": "التفاصيل",
|
"details": "التفاصيل",
|
||||||
"systemActor": "النظام",
|
"systemActor": "النظام",
|
||||||
|
"unknownActor": "مستخدم غير معروف",
|
||||||
"prevPage": "السابق",
|
"prevPage": "السابق",
|
||||||
"nextPage": "التالي"
|
"nextPage": "التالي"
|
||||||
}
|
}
|
||||||
@@ -700,6 +750,8 @@
|
|||||||
"loading": "جاري التحميل...",
|
"loading": "جاري التحميل...",
|
||||||
"live": "مباشر",
|
"live": "مباشر",
|
||||||
"polling": "تحديث دوري",
|
"polling": "تحديث دوري",
|
||||||
|
"allStations": "الكل",
|
||||||
|
"defaultStation": "المطبخ",
|
||||||
"advance": "المرحلة التالية",
|
"advance": "المرحلة التالية",
|
||||||
"status": {
|
"status": {
|
||||||
"Pending": "قيد الانتظار",
|
"Pending": "قيد الانتظار",
|
||||||
@@ -873,6 +925,8 @@
|
|||||||
"newItem": "صنف جديد",
|
"newItem": "صنف جديد",
|
||||||
"newCategory": "فئة جديدة",
|
"newCategory": "فئة جديدة",
|
||||||
"editCategoryTitle": "تعديل الفئة",
|
"editCategoryTitle": "تعديل الفئة",
|
||||||
|
"printStation": "محطة الطباعة",
|
||||||
|
"printStationNone": "طابعة المطبخ (افتراضي)",
|
||||||
"close": "إغلاق",
|
"close": "إغلاق",
|
||||||
"saving": "جاري الحفظ…",
|
"saving": "جاري الحفظ…",
|
||||||
"model3d": "نموذج ثلاثي الأبعاد",
|
"model3d": "نموذج ثلاثي الأبعاد",
|
||||||
@@ -886,7 +940,8 @@
|
|||||||
"deleteItemSuccess": "تم حذف الصنف",
|
"deleteItemSuccess": "تم حذف الصنف",
|
||||||
"deleteCategoryConfirmTitle": "حذف الفئة",
|
"deleteCategoryConfirmTitle": "حذف الفئة",
|
||||||
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
|
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
|
||||||
"deleteCategorySuccess": "تم حذف الفئة"
|
"deleteCategorySuccess": "تم حذف الفئة",
|
||||||
|
"printStationInherit": "نفس الفئة"
|
||||||
},
|
},
|
||||||
"branchMenu": {
|
"branchMenu": {
|
||||||
"title": "قائمة الفرع",
|
"title": "قائمة الفرع",
|
||||||
@@ -1060,6 +1115,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"recentOrders": {
|
||||||
|
"title": "الطلبات الأخيرة",
|
||||||
|
"subtitle": "تصفّح الطلبات المغلقة وأعد طباعة فاتورة العميل وتذاكر المطبخ/البار.",
|
||||||
|
"date": "التاريخ",
|
||||||
|
"branch": "الفرع",
|
||||||
|
"allBranches": "كل الفروع",
|
||||||
|
"empty": "لا توجد طلبات لهذا اليوم.",
|
||||||
|
"loadFailed": "تعذّر تحميل الطلبات.",
|
||||||
|
"retry": "إعادة المحاولة",
|
||||||
|
"prevPage": "السابق",
|
||||||
|
"nextPage": "التالي",
|
||||||
|
"table": "الطاولة",
|
||||||
|
"statusPaid": "مدفوع",
|
||||||
|
"statusCancelled": "ملغى",
|
||||||
|
"receipt": "الفاتورة",
|
||||||
|
"kitchen": "تذكرة المطبخ"
|
||||||
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "الإشعارات",
|
"title": "الإشعارات",
|
||||||
"pageTitle": "الإشعارات",
|
"pageTitle": "الإشعارات",
|
||||||
@@ -1200,13 +1272,51 @@
|
|||||||
"shop": "المقهى والمتجر",
|
"shop": "المقهى والمتجر",
|
||||||
"shopGeneral": "الملف والتكاملات",
|
"shopGeneral": "الملف والتكاملات",
|
||||||
"shopAppearance": "المظهر والألوان",
|
"shopAppearance": "المظهر والألوان",
|
||||||
|
"shopNotifications": "الإشعارات والصوت",
|
||||||
"printer": "الطابعة",
|
"printer": "الطابعة",
|
||||||
"printerSettings": "إعدادات الطابعة",
|
"printerSettings": "إعدادات الطابعة",
|
||||||
|
"printerStations": "طابعات المطبخ والبار",
|
||||||
"printTest": "صفحة اختبار الطباعة",
|
"printTest": "صفحة اختبار الطباعة",
|
||||||
"shopDiscover": "اكتشاف و AI",
|
"shopDiscover": "اكتشاف و AI",
|
||||||
"team": "الفريق والموظفون",
|
"team": "الفريق والموظفون",
|
||||||
"customRoles": "الأدوار المخصصة"
|
"customRoles": "الأدوار المخصصة"
|
||||||
},
|
},
|
||||||
|
"notifPrefs": {
|
||||||
|
"soundSection": "الصوت",
|
||||||
|
"soundEnabled": "تشغيل صوت للإشعارات الجديدة",
|
||||||
|
"soundEnabledHint": "يصدر صوتًا عند وصول طلب جديد أو نداء نادل أو تنبيه.",
|
||||||
|
"soundChoice": "صوت الإشعار",
|
||||||
|
"preview": "معاينة",
|
||||||
|
"volume": "مستوى الصوت",
|
||||||
|
"soundClassic": "كلاسيكي",
|
||||||
|
"soundDing": "رنين",
|
||||||
|
"soundBell": "جرس",
|
||||||
|
"soundChime": "أجراس",
|
||||||
|
"soundMarimba": "ماريمبا",
|
||||||
|
"soundAlert": "تنبيه",
|
||||||
|
"desktopSection": "إشعارات سطح المكتب",
|
||||||
|
"desktopHint": "إظهار نافذة منبثقة على ويندوز/سطح المكتب حتى عندما تكون لوحة التحكم في تبويب آخر أو مصغّرة.",
|
||||||
|
"enableDesktop": "تفعيل إشعارات سطح المكتب",
|
||||||
|
"desktopEnabled": "نوافذ سطح المكتب",
|
||||||
|
"desktopEnabledHint": "تظهر فقط عندما لا يكون هذا التبويب نشطًا.",
|
||||||
|
"desktopGranted": "تم تفعيل إشعارات سطح المكتب",
|
||||||
|
"desktopDenied": "تم رفض الإذن من المتصفح",
|
||||||
|
"desktopBlocked": "الإشعارات محظورة لهذا الموقع. اسمح بها من إعدادات الموقع في المتصفح ثم أعد التحميل.",
|
||||||
|
"desktopUnsupported": "هذا المتصفح لا يدعم إشعارات سطح المكتب.",
|
||||||
|
"desktopFocusNote": "تظهر النافذة التجريبية فقط إذا انتقلت إلى نافذة أخرى أولًا.",
|
||||||
|
"sendTest": "إرسال إشعار تجريبي",
|
||||||
|
"testTitle": "ميزي",
|
||||||
|
"testBody": "هذا إشعار تجريبي.",
|
||||||
|
"testToast": "تم إرسال الإشعار التجريبي",
|
||||||
|
"inAppSection": "داخل التطبيق",
|
||||||
|
"tabBadge": "عدد غير المقروء على تبويب المتصفح",
|
||||||
|
"tabBadgeHint": "يعرض عدد الإشعارات غير المقروءة في عنوان التبويب والأيقونة المفضلة.",
|
||||||
|
"toast": "تنبيه داخل التطبيق",
|
||||||
|
"toastHint": "إظهار شريط صغير داخل لوحة التحكم للإشعارات الجديدة.",
|
||||||
|
"promptTitle": "تفعيل الإشعارات؟",
|
||||||
|
"promptBody": "احصل على نافذة منبثقة وصوت للطلبات الجديدة ونداءات النادل — حتى عندما يكون هذا التبويب في الخلفية.",
|
||||||
|
"later": "لاحقًا"
|
||||||
|
},
|
||||||
"customRoles": {
|
"customRoles": {
|
||||||
"title": "الأدوار المخصصة",
|
"title": "الأدوار المخصصة",
|
||||||
"subtitle": "حدّد أدواراً بصلاحيات مخصصة لموظفيك",
|
"subtitle": "حدّد أدواراً بصلاحيات مخصصة لموظفيك",
|
||||||
@@ -1222,34 +1332,106 @@
|
|||||||
"saveError": "فشل حفظ الدور",
|
"saveError": "فشل حفظ الدور",
|
||||||
"deleteConfirm": "حذف الدور «{name}»؟ سيعود الموظفون إلى صلاحيات دورهم الأساسي.",
|
"deleteConfirm": "حذف الدور «{name}»؟ سيعود الموظفون إلى صلاحيات دورهم الأساسي.",
|
||||||
"groupAdmin": "إدارة المقهى",
|
"groupAdmin": "إدارة المقهى",
|
||||||
"groupMenu": "القائمة والمخزون",
|
"groupBranches": "الفروع",
|
||||||
"groupStaff": "الموظفون",
|
"groupMenu": "القائمة",
|
||||||
"groupCustomer": "العملاء والطاولات",
|
"groupInventory": "المخزون",
|
||||||
|
"groupTaxes": "الضرائب",
|
||||||
|
"groupStaff": "الموظفون والموارد البشرية",
|
||||||
|
"groupTables": "الطاولات والحجوزات",
|
||||||
|
"groupOrders": "الطلبات ونقطة البيع",
|
||||||
|
"groupRegister": "الصندوق والنقد",
|
||||||
|
"groupQueueKitchen": "الانتظار والمطبخ",
|
||||||
|
"groupDelivery": "التوصيل",
|
||||||
|
"groupCustomers": "العملاء",
|
||||||
|
"groupCoupons": "الكوبونات",
|
||||||
|
"groupMarketing": "التسويق والتقييمات",
|
||||||
"groupReports": "التقارير والمالية",
|
"groupReports": "التقارير والمالية",
|
||||||
"groupOps": "عمليات الصندوق",
|
"groupExpenses": "المصروفات",
|
||||||
"groupKitchen": "المطبخ والتوصيل",
|
|
||||||
"perm": {
|
"perm": {
|
||||||
"ManageCafeSettings": "إعدادات المقهى",
|
"ViewCafeSettings": "عرض إعدادات المقهى",
|
||||||
"ManageBilling": "الاشتراك والفواتير",
|
"ManageCafeSettings": "تعديل إعدادات المقهى",
|
||||||
"ManageBranches": "إدارة الفروع",
|
"ManageDiscoverProfile": "الملف العام و«كوجا»",
|
||||||
"ManageMenu": "إدارة القائمة",
|
"ViewBilling": "عرض الفواتير",
|
||||||
"ManageInventory": "المخزون",
|
"ManageBilling": "إدارة الاشتراك والفواتير",
|
||||||
"ManageTaxes": "الضرائب",
|
"ManageRoles": "إدارة الأدوار",
|
||||||
"ManagePrintSettings": "إعدادات الطباعة",
|
"ViewPrintSettings": "عرض إعدادات الطباعة",
|
||||||
"ManageStaff": "إدارة الموظفين",
|
"ManagePrintSettings": "تعديل إعدادات الطباعة",
|
||||||
"ManageSalaries": "الرواتب",
|
"ViewBranches": "عرض الفروع",
|
||||||
"ReviewLeave": "طلبات الإجازة",
|
"CreateBranch": "إنشاء فرع",
|
||||||
"ManageReservations": "الحجوزات",
|
"EditBranch": "تعديل فرع",
|
||||||
"ManageTables": "الطاولات",
|
"DeleteBranch": "حذف فرع",
|
||||||
"ManageCoupons": "الكوبونات",
|
"ViewMenu": "عرض القائمة",
|
||||||
"ViewReports": "التقارير",
|
"CreateMenuItem": "إضافة أصناف",
|
||||||
"ManageExpenses": "المصروفات",
|
"EditMenuItem": "تعديل الأصناف",
|
||||||
"ProcessOrders": "معالجة الطلبات",
|
"DeleteMenuItem": "حذف الأصناف",
|
||||||
"HandlePayments": "المدفوعات",
|
"ViewInventory": "عرض المخزون",
|
||||||
"OperateRegister": "الصندوق",
|
"CreateInventory": "إضافة للمخزون",
|
||||||
"ManageQueue": "قائمة الانتظار",
|
"EditInventory": "تعديل المخزون والكميات",
|
||||||
|
"DeleteInventory": "حذف من المخزون",
|
||||||
|
"ViewTaxes": "عرض الضرائب",
|
||||||
|
"CreateTax": "إنشاء ضريبة",
|
||||||
|
"EditTax": "تعديل ضريبة",
|
||||||
|
"DeleteTax": "حذف ضريبة",
|
||||||
|
"ViewStaff": "عرض الموظفين",
|
||||||
|
"CreateStaff": "إضافة موظف",
|
||||||
|
"EditStaff": "تعديل موظف",
|
||||||
|
"DeleteStaff": "حذف موظف",
|
||||||
|
"ManageStaff": "تعيين أدوار الفروع",
|
||||||
|
"ManageStaffCredentials": "إدارة بيانات الدخول",
|
||||||
|
"ViewAttendance": "عرض الحضور",
|
||||||
|
"ManageAttendance": "إدارة الحضور",
|
||||||
|
"ViewSchedules": "عرض المناوبات",
|
||||||
|
"ManageSchedules": "إدارة المناوبات",
|
||||||
|
"ViewLeave": "عرض طلبات الإجازة",
|
||||||
|
"ReviewLeave": "اعتماد الإجازات",
|
||||||
|
"ViewSalaries": "عرض الرواتب",
|
||||||
|
"ManageSalaries": "إدارة الرواتب",
|
||||||
|
"ViewTables": "عرض الطاولات",
|
||||||
|
"ManageTables": "إدارة الطاولات والأقسام",
|
||||||
|
"ViewReservations": "عرض الحجوزات",
|
||||||
|
"CreateReservation": "إنشاء حجز",
|
||||||
|
"EditReservation": "تعديل حجز",
|
||||||
|
"DeleteReservation": "حذف حجز",
|
||||||
|
"ViewOrders": "عرض الطلبات",
|
||||||
|
"ProcessOrders": "تسجيل الطلبات",
|
||||||
|
"EditOrder": "تعديل الطلبات",
|
||||||
|
"VoidOrder": "إبطال / إلغاء الطلبات",
|
||||||
|
"RefundOrder": "استرداد الطلبات",
|
||||||
|
"ApplyDiscount": "تطبيق الخصومات",
|
||||||
|
"CompOrder": "طلب مجاني (ضيافة)",
|
||||||
|
"HandlePayments": "استلام المدفوعات",
|
||||||
|
"UpdateOrderStatus": "تحديث حالة الطلب",
|
||||||
|
"OperateRegister": "فتح / إغلاق الصندوق",
|
||||||
|
"OpenCashDrawer": "فتح درج النقود (بدون بيع)",
|
||||||
|
"ViewQueue": "عرض قائمة الانتظار",
|
||||||
|
"ManageQueue": "إدارة قائمة الانتظار",
|
||||||
"ViewKitchen": "شاشة المطبخ",
|
"ViewKitchen": "شاشة المطبخ",
|
||||||
"HandleDelivery": "التوصيل"
|
"ManageKitchenStations": "إدارة محطات المطبخ",
|
||||||
|
"ViewDelivery": "عرض التوصيل",
|
||||||
|
"HandleDelivery": "إدارة التوصيل",
|
||||||
|
"AssignDelivery": "تعيين السائق",
|
||||||
|
"ViewCustomers": "عرض العملاء",
|
||||||
|
"CreateCustomer": "إضافة عميل",
|
||||||
|
"EditCustomer": "تعديل عميل",
|
||||||
|
"DeleteCustomer": "حذف عميل",
|
||||||
|
"ViewCoupons": "عرض الكوبونات",
|
||||||
|
"CreateCoupon": "إنشاء كوبون",
|
||||||
|
"EditCoupon": "تعديل كوبون",
|
||||||
|
"DeleteCoupon": "حذف كوبون",
|
||||||
|
"ViewSms": "عرض الرسائل",
|
||||||
|
"SendSms": "إرسال حملات الرسائل",
|
||||||
|
"ManageSmsSettings": "إعدادات الرسائل",
|
||||||
|
"ViewReviews": "عرض التقييمات",
|
||||||
|
"ManageReviews": "الرد على التقييمات وإدارتها",
|
||||||
|
"ViewReports": "عرض التقارير",
|
||||||
|
"ExportReports": "تصدير التقارير",
|
||||||
|
"ViewAuditLog": "عرض سجل العمليات",
|
||||||
|
"ViewFinancials": "عرض المالية (الأرباح والخسائر)",
|
||||||
|
"ManageFinancials": "تصحيح سندات الدفع",
|
||||||
|
"ViewExpenses": "عرض المصروفات",
|
||||||
|
"CreateExpense": "إضافة مصروف",
|
||||||
|
"EditExpense": "تعديل مصروف",
|
||||||
|
"DeleteExpense": "حذف مصروف"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
|
|||||||
+210
-28
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"close": "Close",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"auth": {
|
"auth": {
|
||||||
"title": "Sign in to Meezi",
|
"title": "Sign in to Meezi",
|
||||||
"subtitle": "We will send a verification code to your phone",
|
"subtitle": "We will send a verification code to your phone",
|
||||||
|
"redirecting": "Already signed in — redirecting…",
|
||||||
"phone": "Mobile number",
|
"phone": "Mobile number",
|
||||||
"phonePlaceholder": "09121234567",
|
"phonePlaceholder": "09121234567",
|
||||||
"sendOtp": "Send code",
|
"sendOtp": "Send code",
|
||||||
@@ -127,6 +129,7 @@
|
|||||||
"crm": "CRM",
|
"crm": "CRM",
|
||||||
"coupons": "Coupons",
|
"coupons": "Coupons",
|
||||||
"menu": "Menu",
|
"menu": "Menu",
|
||||||
|
"orders": "Orders",
|
||||||
"inventory": "Inventory",
|
"inventory": "Inventory",
|
||||||
"hr": "HR",
|
"hr": "HR",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
@@ -348,7 +351,49 @@
|
|||||||
"configurePrinters": "Open printer settings",
|
"configurePrinters": "Open printer settings",
|
||||||
"posDeviceSection": "Card POS terminal",
|
"posDeviceSection": "Card POS terminal",
|
||||||
"posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.",
|
"posDeviceHint": "On card payment, the amount is sent via HTTP (POST /pay) to the device on your LAN.",
|
||||||
"posDeviceIp": "POS device IP address"
|
"posDeviceIp": "POS device IP address",
|
||||||
|
"detect": "Auto-detect",
|
||||||
|
"detecting": "Scanning the network…",
|
||||||
|
"detectNone": "No devices found on the network",
|
||||||
|
"detectOffline": "A print server must be online to auto-detect",
|
||||||
|
"detectHint": "The print server scans your LAN to find the device.",
|
||||||
|
"testSent": "Test sent to the printer.",
|
||||||
|
"sent": "Sent to the printer.",
|
||||||
|
"noStationItems": "This order has no items for that station.",
|
||||||
|
"printFailed": "Print failed.",
|
||||||
|
"stations": {
|
||||||
|
"title": "Kitchen & bar print stations",
|
||||||
|
"subtitle": "Give each prep area its own printer and route menu categories to it.",
|
||||||
|
"help": "Create a station (e.g. Kitchen, Bar) with its own printer, then in Menu set each category’s print station — food → Kitchen, drinks → Bar. Items in a category with no station fall back to the branch kitchen printer. The customer receipt always prints to the receipt printer.",
|
||||||
|
"add": "Add station",
|
||||||
|
"name": "Station name",
|
||||||
|
"namePlaceholder": "e.g. Kitchen, Bar",
|
||||||
|
"printerIp": "Printer IP",
|
||||||
|
"noPrinter": "No printer — uses the kitchen printer",
|
||||||
|
"categoryCount": "{count} categories",
|
||||||
|
"test": "Test",
|
||||||
|
"empty": "No stations yet. Add Kitchen and Bar to print their items separately.",
|
||||||
|
"deleteConfirm": "Delete station “{name}”? Its categories will fall back to the kitchen printer.",
|
||||||
|
"saveError": "Failed to save the station."
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"title": "Print servers (auto-discovery)",
|
||||||
|
"hint": "Install the Meezi print agent on the cash PC to auto-detect its USB & network printers and print through it.",
|
||||||
|
"add": "Add print server",
|
||||||
|
"pairingTitle": "Enter this code in the print agent on the cash PC:",
|
||||||
|
"pairingSteps": "Install and run the Meezi Print Agent on the PC connected to the printers, then enter this code. It is valid for 15 minutes.",
|
||||||
|
"empty": "No print server connected yet.",
|
||||||
|
"online": "Online",
|
||||||
|
"offline": "Offline",
|
||||||
|
"noDevices": "Discovering printers…",
|
||||||
|
"test": "Test",
|
||||||
|
"receiptVia": "Receipt printer (via server)",
|
||||||
|
"kitchenVia": "Kitchen printer (via server)",
|
||||||
|
"viaServer": "Printer (via server)",
|
||||||
|
"useIpInstead": "— Use manual IP —",
|
||||||
|
"revokeConfirm": "Remove print server “{name}”? It will no longer be able to print.",
|
||||||
|
"codeError": "Could not create code."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"receipt": {
|
"receipt": {
|
||||||
"table": "Table",
|
"table": "Table",
|
||||||
@@ -435,7 +480,8 @@
|
|||||||
"payroll": "Payroll",
|
"payroll": "Payroll",
|
||||||
"access": "Branch access",
|
"access": "Branch access",
|
||||||
"credentials": "Login credentials",
|
"credentials": "Login credentials",
|
||||||
"team": "Team"
|
"team": "Team",
|
||||||
|
"roles": "Roles & permissions"
|
||||||
},
|
},
|
||||||
"myAttendance": "My attendance",
|
"myAttendance": "My attendance",
|
||||||
"clockIn": "Clock in",
|
"clockIn": "Clock in",
|
||||||
@@ -464,6 +510,9 @@
|
|||||||
"addEmployee": "Add employee",
|
"addEmployee": "Add employee",
|
||||||
"noEmployees": "No employees yet.",
|
"noEmployees": "No employees yet.",
|
||||||
"employeeCreated": "Employee added",
|
"employeeCreated": "Employee added",
|
||||||
|
"employeeDetails": "Employee details",
|
||||||
|
"employeeNotFound": "This user is no longer active.",
|
||||||
|
"openInHr": "Open in HR",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"fields": {
|
"fields": {
|
||||||
@@ -650,6 +699,7 @@
|
|||||||
"colSummary": "Summary",
|
"colSummary": "Summary",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"systemActor": "System",
|
"systemActor": "System",
|
||||||
|
"unknownActor": "Unknown user",
|
||||||
"prevPage": "Previous",
|
"prevPage": "Previous",
|
||||||
"nextPage": "Next"
|
"nextPage": "Next"
|
||||||
}
|
}
|
||||||
@@ -734,6 +784,8 @@
|
|||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"live": "Live",
|
"live": "Live",
|
||||||
"polling": "Polling",
|
"polling": "Polling",
|
||||||
|
"allStations": "All",
|
||||||
|
"defaultStation": "Kitchen",
|
||||||
"advance": "Next step",
|
"advance": "Next step",
|
||||||
"status": {
|
"status": {
|
||||||
"Pending": "Pending",
|
"Pending": "Pending",
|
||||||
@@ -907,6 +959,8 @@
|
|||||||
"newItem": "New item",
|
"newItem": "New item",
|
||||||
"newCategory": "New category",
|
"newCategory": "New category",
|
||||||
"editCategoryTitle": "Edit category",
|
"editCategoryTitle": "Edit category",
|
||||||
|
"printStation": "Print station",
|
||||||
|
"printStationNone": "Kitchen printer (default)",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"model3d": "3D model",
|
"model3d": "3D model",
|
||||||
@@ -920,7 +974,8 @@
|
|||||||
"deleteItemSuccess": "Item deleted",
|
"deleteItemSuccess": "Item deleted",
|
||||||
"deleteCategoryConfirmTitle": "Delete category",
|
"deleteCategoryConfirmTitle": "Delete category",
|
||||||
"deleteCategoryConfirmDesc": "Are you sure you want to delete the “{name}” category?",
|
"deleteCategoryConfirmDesc": "Are you sure you want to delete the “{name}” category?",
|
||||||
"deleteCategorySuccess": "Category deleted"
|
"deleteCategorySuccess": "Category deleted",
|
||||||
|
"printStationInherit": "Same as category"
|
||||||
},
|
},
|
||||||
"branchMenu": {
|
"branchMenu": {
|
||||||
"title": "Branch Menu",
|
"title": "Branch Menu",
|
||||||
@@ -1120,6 +1175,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"recentOrders": {
|
||||||
|
"title": "Recent orders",
|
||||||
|
"subtitle": "Browse closed orders and reprint the customer receipt and the kitchen / bar tickets.",
|
||||||
|
"date": "Date",
|
||||||
|
"branch": "Branch",
|
||||||
|
"allBranches": "All branches",
|
||||||
|
"empty": "No orders for this day.",
|
||||||
|
"loadFailed": "Could not load orders.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"prevPage": "Previous",
|
||||||
|
"nextPage": "Next",
|
||||||
|
"table": "Table",
|
||||||
|
"statusPaid": "Paid",
|
||||||
|
"statusCancelled": "Cancelled",
|
||||||
|
"receipt": "Receipt",
|
||||||
|
"kitchen": "Kitchen ticket"
|
||||||
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"pageTitle": "Notifications",
|
"pageTitle": "Notifications",
|
||||||
@@ -1272,13 +1344,51 @@
|
|||||||
"shop": "Shop & café",
|
"shop": "Shop & café",
|
||||||
"shopGeneral": "Profile & integrations",
|
"shopGeneral": "Profile & integrations",
|
||||||
"shopAppearance": "Appearance & colors",
|
"shopAppearance": "Appearance & colors",
|
||||||
|
"shopNotifications": "Notifications & sound",
|
||||||
"printer": "Printer",
|
"printer": "Printer",
|
||||||
"printerSettings": "Printer settings",
|
"printerSettings": "Printer settings",
|
||||||
|
"printerStations": "Kitchen & bar printers",
|
||||||
"printTest": "Print test page",
|
"printTest": "Print test page",
|
||||||
"shopDiscover": "Discover & AI",
|
"shopDiscover": "Discover & AI",
|
||||||
"team": "Team & Staff",
|
"team": "Team & Staff",
|
||||||
"customRoles": "Custom Roles"
|
"customRoles": "Custom Roles"
|
||||||
},
|
},
|
||||||
|
"notifPrefs": {
|
||||||
|
"soundSection": "Sound",
|
||||||
|
"soundEnabled": "Play a sound for new notifications",
|
||||||
|
"soundEnabledHint": "Chimes when a new order, waiter call, or alert arrives.",
|
||||||
|
"soundChoice": "Notification sound",
|
||||||
|
"preview": "Preview",
|
||||||
|
"volume": "Volume",
|
||||||
|
"soundClassic": "Classic",
|
||||||
|
"soundDing": "Ding",
|
||||||
|
"soundBell": "Bell",
|
||||||
|
"soundChime": "Chime",
|
||||||
|
"soundMarimba": "Marimba",
|
||||||
|
"soundAlert": "Alert",
|
||||||
|
"desktopSection": "Desktop notifications",
|
||||||
|
"desktopHint": "Show a Windows/desktop popup even when the dashboard is in another tab or minimized.",
|
||||||
|
"enableDesktop": "Enable desktop notifications",
|
||||||
|
"desktopEnabled": "Desktop popups",
|
||||||
|
"desktopEnabledHint": "Pop up only when this tab is not focused.",
|
||||||
|
"desktopGranted": "Desktop notifications enabled",
|
||||||
|
"desktopDenied": "Permission denied by the browser",
|
||||||
|
"desktopBlocked": "Notifications are blocked for this site. Allow them in your browser's site settings, then reload.",
|
||||||
|
"desktopUnsupported": "This browser does not support desktop notifications.",
|
||||||
|
"desktopFocusNote": "A test popup only appears if you switch to another window first.",
|
||||||
|
"sendTest": "Send a test notification",
|
||||||
|
"testTitle": "Meezi",
|
||||||
|
"testBody": "This is a test notification.",
|
||||||
|
"testToast": "Test sent",
|
||||||
|
"inAppSection": "In-app",
|
||||||
|
"tabBadge": "Unread count on the browser tab",
|
||||||
|
"tabBadgeHint": "Shows the number of unread notifications in the tab title and favicon.",
|
||||||
|
"toast": "In-app toast",
|
||||||
|
"toastHint": "Show a small banner inside the dashboard for new notifications.",
|
||||||
|
"promptTitle": "Turn on notifications?",
|
||||||
|
"promptBody": "Get a popup + sound for new orders and waiter calls — even when this tab is in the background.",
|
||||||
|
"later": "Later"
|
||||||
|
},
|
||||||
"customRoles": {
|
"customRoles": {
|
||||||
"title": "Custom Roles",
|
"title": "Custom Roles",
|
||||||
"subtitle": "Define roles with tailored permissions for your staff",
|
"subtitle": "Define roles with tailored permissions for your staff",
|
||||||
@@ -1294,34 +1404,106 @@
|
|||||||
"saveError": "Failed to save role",
|
"saveError": "Failed to save role",
|
||||||
"deleteConfirm": "Delete role '{name}'? Employees will revert to their base role permissions.",
|
"deleteConfirm": "Delete role '{name}'? Employees will revert to their base role permissions.",
|
||||||
"groupAdmin": "Café Administration",
|
"groupAdmin": "Café Administration",
|
||||||
"groupMenu": "Menu & Inventory",
|
"groupBranches": "Branches",
|
||||||
"groupStaff": "Staff",
|
"groupMenu": "Menu",
|
||||||
"groupCustomer": "Customer & Tables",
|
"groupInventory": "Inventory",
|
||||||
|
"groupTaxes": "Taxes",
|
||||||
|
"groupStaff": "Staff & HR",
|
||||||
|
"groupTables": "Tables & Reservations",
|
||||||
|
"groupOrders": "Orders & POS",
|
||||||
|
"groupRegister": "Register & Cash",
|
||||||
|
"groupQueueKitchen": "Queue & Kitchen",
|
||||||
|
"groupDelivery": "Delivery",
|
||||||
|
"groupCustomers": "Customers",
|
||||||
|
"groupCoupons": "Coupons",
|
||||||
|
"groupMarketing": "Marketing & Reviews",
|
||||||
"groupReports": "Reports & Finance",
|
"groupReports": "Reports & Finance",
|
||||||
"groupOps": "Register Operations",
|
"groupExpenses": "Expenses",
|
||||||
"groupKitchen": "Kitchen & Delivery",
|
|
||||||
"perm": {
|
"perm": {
|
||||||
"ManageCafeSettings": "Café settings",
|
"ViewCafeSettings": "View café settings",
|
||||||
"ManageBilling": "Billing & subscription",
|
"ManageCafeSettings": "Edit café settings",
|
||||||
"ManageBranches": "Manage branches",
|
"ManageDiscoverProfile": "Discover & public profile",
|
||||||
"ManageMenu": "Menu management",
|
"ViewBilling": "View billing",
|
||||||
"ManageInventory": "Inventory",
|
"ManageBilling": "Manage billing & subscription",
|
||||||
"ManageTaxes": "Taxes",
|
"ManageRoles": "Manage roles",
|
||||||
"ManagePrintSettings": "Print settings",
|
"ViewPrintSettings": "View print settings",
|
||||||
"ManageStaff": "Staff management",
|
"ManagePrintSettings": "Edit print settings",
|
||||||
"ManageSalaries": "Salaries",
|
"ViewBranches": "View branches",
|
||||||
"ReviewLeave": "Leave requests",
|
"CreateBranch": "Create branch",
|
||||||
"ManageReservations": "Reservations",
|
"EditBranch": "Edit branch",
|
||||||
"ManageTables": "Tables",
|
"DeleteBranch": "Delete branch",
|
||||||
"ManageCoupons": "Coupons",
|
"ViewMenu": "View menu",
|
||||||
"ViewReports": "Reports",
|
"CreateMenuItem": "Add menu items",
|
||||||
"ManageExpenses": "Expenses",
|
"EditMenuItem": "Edit menu items",
|
||||||
"ProcessOrders": "Process orders",
|
"DeleteMenuItem": "Delete menu items",
|
||||||
"HandlePayments": "Handle payments",
|
"ViewInventory": "View inventory",
|
||||||
"OperateRegister": "Register",
|
"CreateInventory": "Add inventory",
|
||||||
"ManageQueue": "Queue",
|
"EditInventory": "Edit inventory & stock",
|
||||||
|
"DeleteInventory": "Delete inventory",
|
||||||
|
"ViewTaxes": "View taxes",
|
||||||
|
"CreateTax": "Create tax",
|
||||||
|
"EditTax": "Edit tax",
|
||||||
|
"DeleteTax": "Delete tax",
|
||||||
|
"ViewStaff": "View staff",
|
||||||
|
"CreateStaff": "Add staff",
|
||||||
|
"EditStaff": "Edit staff",
|
||||||
|
"DeleteStaff": "Remove staff",
|
||||||
|
"ManageStaff": "Assign branch roles",
|
||||||
|
"ManageStaffCredentials": "Manage login credentials",
|
||||||
|
"ViewAttendance": "View attendance",
|
||||||
|
"ManageAttendance": "Manage attendance",
|
||||||
|
"ViewSchedules": "View schedules",
|
||||||
|
"ManageSchedules": "Manage schedules",
|
||||||
|
"ViewLeave": "View leave requests",
|
||||||
|
"ReviewLeave": "Approve leave requests",
|
||||||
|
"ViewSalaries": "View salaries",
|
||||||
|
"ManageSalaries": "Manage salaries",
|
||||||
|
"ViewTables": "View tables",
|
||||||
|
"ManageTables": "Manage tables & sections",
|
||||||
|
"ViewReservations": "View reservations",
|
||||||
|
"CreateReservation": "Create reservation",
|
||||||
|
"EditReservation": "Edit reservation",
|
||||||
|
"DeleteReservation": "Delete reservation",
|
||||||
|
"ViewOrders": "View orders",
|
||||||
|
"ProcessOrders": "Take orders",
|
||||||
|
"EditOrder": "Edit orders",
|
||||||
|
"VoidOrder": "Void / cancel orders",
|
||||||
|
"RefundOrder": "Refund orders",
|
||||||
|
"ApplyDiscount": "Apply discounts",
|
||||||
|
"CompOrder": "Comp (free) orders",
|
||||||
|
"HandlePayments": "Take payments",
|
||||||
|
"UpdateOrderStatus": "Update order status",
|
||||||
|
"OperateRegister": "Open / close register",
|
||||||
|
"OpenCashDrawer": "Open cash drawer (no-sale)",
|
||||||
|
"ViewQueue": "View queue",
|
||||||
|
"ManageQueue": "Manage queue",
|
||||||
"ViewKitchen": "Kitchen display",
|
"ViewKitchen": "Kitchen display",
|
||||||
"HandleDelivery": "Delivery"
|
"ManageKitchenStations": "Manage kitchen stations",
|
||||||
|
"ViewDelivery": "View delivery",
|
||||||
|
"HandleDelivery": "Handle delivery",
|
||||||
|
"AssignDelivery": "Assign delivery",
|
||||||
|
"ViewCustomers": "View customers",
|
||||||
|
"CreateCustomer": "Add customers",
|
||||||
|
"EditCustomer": "Edit customers",
|
||||||
|
"DeleteCustomer": "Delete customers",
|
||||||
|
"ViewCoupons": "View coupons",
|
||||||
|
"CreateCoupon": "Create coupon",
|
||||||
|
"EditCoupon": "Edit coupon",
|
||||||
|
"DeleteCoupon": "Delete coupon",
|
||||||
|
"ViewSms": "View SMS",
|
||||||
|
"SendSms": "Send SMS campaigns",
|
||||||
|
"ManageSmsSettings": "SMS settings",
|
||||||
|
"ViewReviews": "View reviews",
|
||||||
|
"ManageReviews": "Reply & moderate reviews",
|
||||||
|
"ViewReports": "View reports",
|
||||||
|
"ExportReports": "Export reports",
|
||||||
|
"ViewAuditLog": "View audit log",
|
||||||
|
"ViewFinancials": "View financials (P&L)",
|
||||||
|
"ManageFinancials": "Payment corrections",
|
||||||
|
"ViewExpenses": "View expenses",
|
||||||
|
"CreateExpense": "Add expense",
|
||||||
|
"EditExpense": "Edit expense",
|
||||||
|
"DeleteExpense": "Delete expense"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
|
|||||||
+209
-27
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"save": "ذخیره",
|
"save": "ذخیره",
|
||||||
|
"close": "بستن",
|
||||||
"cancel": "انصراف",
|
"cancel": "انصراف",
|
||||||
"confirm": "تأیید",
|
"confirm": "تأیید",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"auth": {
|
"auth": {
|
||||||
"title": "ورود به میزی",
|
"title": "ورود به میزی",
|
||||||
"subtitle": "کد تأیید به موبایل شما ارسال میشود",
|
"subtitle": "کد تأیید به موبایل شما ارسال میشود",
|
||||||
|
"redirecting": "قبلاً وارد شدهاید — در حال انتقال…",
|
||||||
"phone": "شماره موبایل",
|
"phone": "شماره موبایل",
|
||||||
"phonePlaceholder": "۰۹۱۲۱۲۳۴۵۶۷",
|
"phonePlaceholder": "۰۹۱۲۱۲۳۴۵۶۷",
|
||||||
"sendOtp": "ارسال کد",
|
"sendOtp": "ارسال کد",
|
||||||
@@ -127,6 +129,7 @@
|
|||||||
"crm": "مشتریان",
|
"crm": "مشتریان",
|
||||||
"coupons": "کوپنها",
|
"coupons": "کوپنها",
|
||||||
"menu": "منو",
|
"menu": "منو",
|
||||||
|
"orders": "سفارشها",
|
||||||
"inventory": "انبار",
|
"inventory": "انبار",
|
||||||
"hr": "منابع انسانی",
|
"hr": "منابع انسانی",
|
||||||
"reports": "گزارشها",
|
"reports": "گزارشها",
|
||||||
@@ -348,7 +351,49 @@
|
|||||||
"configurePrinters": "رفتن به تنظیمات پرینتر",
|
"configurePrinters": "رفتن به تنظیمات پرینتر",
|
||||||
"posDeviceSection": "دستگاه پوز (کارتخوان)",
|
"posDeviceSection": "دستگاه پوز (کارتخوان)",
|
||||||
"posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال میشود (POST /pay).",
|
"posDeviceHint": "هنگام پرداخت کارتی، مبلغ به آدرس HTTP دستگاه ارسال میشود (POST /pay).",
|
||||||
"posDeviceIp": "آدرس IP دستگاه پوز"
|
"posDeviceIp": "آدرس IP دستگاه پوز",
|
||||||
|
"detect": "تشخیص خودکار",
|
||||||
|
"detecting": "در حال جستجوی شبکه…",
|
||||||
|
"detectNone": "دستگاهی در شبکه پیدا نشد",
|
||||||
|
"detectOffline": "برای تشخیص خودکار باید پرینتسرور روشن و متصل باشد",
|
||||||
|
"detectHint": "پرینتسرور شبکه محلی را برای یافتن دستگاه اسکن میکند.",
|
||||||
|
"testSent": "تست به پرینتر ارسال شد.",
|
||||||
|
"sent": "به پرینتر ارسال شد.",
|
||||||
|
"noStationItems": "این سفارش آیتمی برای این ایستگاه ندارد.",
|
||||||
|
"printFailed": "چاپ ناموفق بود.",
|
||||||
|
"stations": {
|
||||||
|
"title": "ایستگاههای چاپ آشپزخانه و بار",
|
||||||
|
"subtitle": "برای هر بخش آمادهسازی یک پرینتر جدا بگذارید و دستههای منو را به آن وصل کنید.",
|
||||||
|
"help": "یک ایستگاه (مثلاً آشپزخانه یا بار) با پرینتر مخصوص خودش بسازید، سپس در «منو» برای هر دسته ایستگاه چاپ را انتخاب کنید — غذا ← آشپزخانه، نوشیدنی ← بار. آیتمهای دستههایی که ایستگاه ندارند روی پرینتر آشپزخانهٔ شعبه چاپ میشوند. فاکتور مشتری همیشه روی پرینتر فاکتور چاپ میشود.",
|
||||||
|
"add": "افزودن ایستگاه",
|
||||||
|
"name": "نام ایستگاه",
|
||||||
|
"namePlaceholder": "مثلاً آشپزخانه، بار",
|
||||||
|
"printerIp": "آیپی پرینتر",
|
||||||
|
"noPrinter": "بدون پرینتر — از پرینتر آشپزخانه استفاده میشود",
|
||||||
|
"categoryCount": "{count} دسته",
|
||||||
|
"test": "تست",
|
||||||
|
"empty": "هنوز ایستگاهی ندارید. «آشپزخانه» و «بار» را اضافه کنید تا آیتمهایشان جدا چاپ شود.",
|
||||||
|
"deleteConfirm": "ایستگاه «{name}» حذف شود؟ دستههای آن به پرینتر آشپزخانه برمیگردند.",
|
||||||
|
"saveError": "ذخیرهٔ ایستگاه ناموفق بود."
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"title": "پرینتسرورها (شناسایی خودکار پرینتر)",
|
||||||
|
"hint": "روی کامپیوتر صندوق، برنامهٔ «پرینتسرور میزی» را نصب کنید تا پرینترهای USB و شبکه بهصورت خودکار شناسایی شوند و چاپ از طریق آن انجام شود.",
|
||||||
|
"add": "افزودن پرینتسرور",
|
||||||
|
"pairingTitle": "این کد را در برنامهٔ پرینتسرور روی کامپیوتر صندوق وارد کنید:",
|
||||||
|
"pairingSteps": "برنامهٔ «پرینتسرور میزی» را روی همان کامپیوتری که به پرینترها وصل است نصب و اجرا کنید، سپس این کد را وارد کنید. کد تا ۱۵ دقیقه معتبر است.",
|
||||||
|
"empty": "هنوز پرینتسروری متصل نشده است.",
|
||||||
|
"online": "آنلاین",
|
||||||
|
"offline": "آفلاین",
|
||||||
|
"noDevices": "در حال یافتن پرینترها…",
|
||||||
|
"test": "تست",
|
||||||
|
"receiptVia": "پرینتر رسید (از پرینتسرور)",
|
||||||
|
"kitchenVia": "پرینتر آشپزخانه (از پرینتسرور)",
|
||||||
|
"viaServer": "پرینتر (از پرینتسرور)",
|
||||||
|
"useIpInstead": "— استفاده از IP دستی —",
|
||||||
|
"revokeConfirm": "حذف پرینتسرور «{name}»؟ پس از آن دیگر نمیتواند چاپ کند.",
|
||||||
|
"codeError": "ایجاد کد ناموفق بود."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"receipt": {
|
"receipt": {
|
||||||
"table": "میز",
|
"table": "میز",
|
||||||
@@ -435,7 +480,8 @@
|
|||||||
"payroll": "حقوق",
|
"payroll": "حقوق",
|
||||||
"access": "دسترسی شعب",
|
"access": "دسترسی شعب",
|
||||||
"credentials": "رمز ورود",
|
"credentials": "رمز ورود",
|
||||||
"team": "کارکنان"
|
"team": "کارکنان",
|
||||||
|
"roles": "نقشها و دسترسیها"
|
||||||
},
|
},
|
||||||
"myAttendance": "حضور من",
|
"myAttendance": "حضور من",
|
||||||
"clockIn": "ورود",
|
"clockIn": "ورود",
|
||||||
@@ -464,6 +510,9 @@
|
|||||||
"addEmployee": "افزودن کارمند",
|
"addEmployee": "افزودن کارمند",
|
||||||
"noEmployees": "هنوز کارمندی ثبت نشده است.",
|
"noEmployees": "هنوز کارمندی ثبت نشده است.",
|
||||||
"employeeCreated": "کارمند اضافه شد",
|
"employeeCreated": "کارمند اضافه شد",
|
||||||
|
"employeeDetails": "مشخصات کارمند",
|
||||||
|
"employeeNotFound": "این کاربر دیگر فعال نیست.",
|
||||||
|
"openInHr": "مشاهده در منابع انسانی",
|
||||||
"save": "ذخیره",
|
"save": "ذخیره",
|
||||||
"cancel": "انصراف",
|
"cancel": "انصراف",
|
||||||
"fields": {
|
"fields": {
|
||||||
@@ -650,6 +699,7 @@
|
|||||||
"colSummary": "شرح",
|
"colSummary": "شرح",
|
||||||
"details": "جزئیات",
|
"details": "جزئیات",
|
||||||
"systemActor": "سیستم",
|
"systemActor": "سیستم",
|
||||||
|
"unknownActor": "کاربر نامشخص",
|
||||||
"prevPage": "قبلی",
|
"prevPage": "قبلی",
|
||||||
"nextPage": "بعدی"
|
"nextPage": "بعدی"
|
||||||
}
|
}
|
||||||
@@ -734,6 +784,8 @@
|
|||||||
"loading": "در حال بارگذاری...",
|
"loading": "در حال بارگذاری...",
|
||||||
"live": "زنده",
|
"live": "زنده",
|
||||||
"polling": "بهروزرسانی دورهای",
|
"polling": "بهروزرسانی دورهای",
|
||||||
|
"allStations": "همه",
|
||||||
|
"defaultStation": "آشپزخانه",
|
||||||
"advance": "مرحله بعد",
|
"advance": "مرحله بعد",
|
||||||
"status": {
|
"status": {
|
||||||
"Pending": "در انتظار",
|
"Pending": "در انتظار",
|
||||||
@@ -907,6 +959,8 @@
|
|||||||
"newItem": "آیتم جدید",
|
"newItem": "آیتم جدید",
|
||||||
"newCategory": "دسته جدید",
|
"newCategory": "دسته جدید",
|
||||||
"editCategoryTitle": "ویرایش دسته",
|
"editCategoryTitle": "ویرایش دسته",
|
||||||
|
"printStation": "ایستگاه چاپ",
|
||||||
|
"printStationNone": "پرینتر آشپزخانه (پیشفرض)",
|
||||||
"close": "بستن",
|
"close": "بستن",
|
||||||
"saving": "در حال ذخیره…",
|
"saving": "در حال ذخیره…",
|
||||||
"model3d": "مدل سهبعدی",
|
"model3d": "مدل سهبعدی",
|
||||||
@@ -920,7 +974,8 @@
|
|||||||
"deleteItemSuccess": "آیتم حذف شد",
|
"deleteItemSuccess": "آیتم حذف شد",
|
||||||
"deleteCategoryConfirmTitle": "حذف دستهبندی",
|
"deleteCategoryConfirmTitle": "حذف دستهبندی",
|
||||||
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
|
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
|
||||||
"deleteCategorySuccess": "دسته حذف شد"
|
"deleteCategorySuccess": "دسته حذف شد",
|
||||||
|
"printStationInherit": "مثل دستهٔ منو"
|
||||||
},
|
},
|
||||||
"branchMenu": {
|
"branchMenu": {
|
||||||
"title": "منوی شعبه",
|
"title": "منوی شعبه",
|
||||||
@@ -1121,6 +1176,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"recentOrders": {
|
||||||
|
"title": "سفارشهای اخیر",
|
||||||
|
"subtitle": "سفارشهای بستهشده را ببینید و فاکتور مشتری و فیش آشپزخانه/بار را دوباره چاپ کنید.",
|
||||||
|
"date": "تاریخ",
|
||||||
|
"branch": "شعبه",
|
||||||
|
"allBranches": "همه شعب",
|
||||||
|
"empty": "سفارشی برای این روز نیست.",
|
||||||
|
"loadFailed": "بارگذاری سفارشها ناموفق بود.",
|
||||||
|
"retry": "تلاش مجدد",
|
||||||
|
"prevPage": "قبلی",
|
||||||
|
"nextPage": "بعدی",
|
||||||
|
"table": "میز",
|
||||||
|
"statusPaid": "پرداختشده",
|
||||||
|
"statusCancelled": "لغوشده",
|
||||||
|
"receipt": "فاکتور",
|
||||||
|
"kitchen": "فیش آشپزخانه"
|
||||||
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "اعلانها",
|
"title": "اعلانها",
|
||||||
"pageTitle": "اعلانها",
|
"pageTitle": "اعلانها",
|
||||||
@@ -1273,13 +1345,51 @@
|
|||||||
"shop": "کافه و فروشگاه",
|
"shop": "کافه و فروشگاه",
|
||||||
"shopGeneral": "پروفایل و اتصالها",
|
"shopGeneral": "پروفایل و اتصالها",
|
||||||
"shopAppearance": "ظاهر و رنگبندی",
|
"shopAppearance": "ظاهر و رنگبندی",
|
||||||
|
"shopNotifications": "اعلانها و صدا",
|
||||||
"printer": "پرینتر",
|
"printer": "پرینتر",
|
||||||
"printerSettings": "تنظیمات پرینتر",
|
"printerSettings": "تنظیمات پرینتر",
|
||||||
|
"printerStations": "پرینتر آشپزخانه و بار",
|
||||||
"printTest": "صفحه تست چاپ",
|
"printTest": "صفحه تست چاپ",
|
||||||
"shopDiscover": "کشف و AI",
|
"shopDiscover": "کشف و AI",
|
||||||
"team": "تیم و کارمندان",
|
"team": "تیم و کارمندان",
|
||||||
"customRoles": "نقشهای سفارشی"
|
"customRoles": "نقشهای سفارشی"
|
||||||
},
|
},
|
||||||
|
"notifPrefs": {
|
||||||
|
"soundSection": "صدا",
|
||||||
|
"soundEnabled": "پخش صدا برای اعلانهای جدید",
|
||||||
|
"soundEnabledHint": "هنگام رسیدن سفارش جدید، درخواست میز یا هشدار، صدا پخش میشود.",
|
||||||
|
"soundChoice": "صدای اعلان",
|
||||||
|
"preview": "پیشنمایش",
|
||||||
|
"volume": "بلندی صدا",
|
||||||
|
"soundClassic": "کلاسیک",
|
||||||
|
"soundDing": "دینگ",
|
||||||
|
"soundBell": "زنگ",
|
||||||
|
"soundChime": "ناقوس",
|
||||||
|
"soundMarimba": "ماریمبا",
|
||||||
|
"soundAlert": "هشدار",
|
||||||
|
"desktopSection": "اعلانهای دسکتاپ",
|
||||||
|
"desktopHint": "نمایش پاپآپ ویندوز/دسکتاپ حتی وقتی داشبورد در تب دیگری باز است یا کوچک شده.",
|
||||||
|
"enableDesktop": "فعالسازی اعلانهای دسکتاپ",
|
||||||
|
"desktopEnabled": "پاپآپ دسکتاپ",
|
||||||
|
"desktopEnabledHint": "فقط وقتی این تب فعال نیست نمایش داده میشود.",
|
||||||
|
"desktopGranted": "اعلانهای دسکتاپ فعال شد",
|
||||||
|
"desktopDenied": "دسترسی توسط مرورگر رد شد",
|
||||||
|
"desktopBlocked": "اعلانها برای این سایت مسدود شدهاند. از تنظیمات سایت در مرورگر اجازه دهید و سپس صفحه را دوباره بارگذاری کنید.",
|
||||||
|
"desktopUnsupported": "این مرورگر از اعلانهای دسکتاپ پشتیبانی نمیکند.",
|
||||||
|
"desktopFocusNote": "پاپآپ آزمایشی فقط زمانی نمایش داده میشود که ابتدا به پنجره دیگری بروید.",
|
||||||
|
"sendTest": "ارسال اعلان آزمایشی",
|
||||||
|
"testTitle": "میزی",
|
||||||
|
"testBody": "این یک اعلان آزمایشی است.",
|
||||||
|
"testToast": "اعلان آزمایشی ارسال شد",
|
||||||
|
"inAppSection": "درونبرنامه",
|
||||||
|
"tabBadge": "شمارش خواندهنشده روی تب مرورگر",
|
||||||
|
"tabBadgeHint": "تعداد اعلانهای خواندهنشده را در عنوان تب و فاویکون نشان میدهد.",
|
||||||
|
"toast": "نوتیف درونبرنامه",
|
||||||
|
"toastHint": "نمایش یک بنر کوچک داخل داشبورد برای اعلانهای جدید.",
|
||||||
|
"promptTitle": "اعلانها روشن شود؟",
|
||||||
|
"promptBody": "برای سفارشهای جدید و درخواست میز، پاپآپ و صدا دریافت کنید — حتی وقتی این تب در پسزمینه است.",
|
||||||
|
"later": "بعداً"
|
||||||
|
},
|
||||||
"customRoles": {
|
"customRoles": {
|
||||||
"title": "نقشهای سفارشی",
|
"title": "نقشهای سفارشی",
|
||||||
"subtitle": "نقشهایی با دسترسی دلخواه برای کارمندان تعریف کنید",
|
"subtitle": "نقشهایی با دسترسی دلخواه برای کارمندان تعریف کنید",
|
||||||
@@ -1295,34 +1405,106 @@
|
|||||||
"saveError": "ذخیره نقش ناموفق بود",
|
"saveError": "ذخیره نقش ناموفق بود",
|
||||||
"deleteConfirm": "نقش «{name}» حذف شود؟ این کارمندان به دسترسی پیشفرض نقش اصلی خود بازمیگردند.",
|
"deleteConfirm": "نقش «{name}» حذف شود؟ این کارمندان به دسترسی پیشفرض نقش اصلی خود بازمیگردند.",
|
||||||
"groupAdmin": "مدیریت کافه",
|
"groupAdmin": "مدیریت کافه",
|
||||||
"groupMenu": "منو و انبار",
|
"groupBranches": "شعب",
|
||||||
"groupStaff": "پرسنل",
|
"groupMenu": "منو",
|
||||||
"groupCustomer": "مشتری و میز",
|
"groupInventory": "انبار و موجودی",
|
||||||
|
"groupTaxes": "مالیات",
|
||||||
|
"groupStaff": "پرسنل و منابع انسانی",
|
||||||
|
"groupTables": "میز و رزرو",
|
||||||
|
"groupOrders": "سفارش و فروش",
|
||||||
|
"groupRegister": "صندوق و وجه نقد",
|
||||||
|
"groupQueueKitchen": "صف و آشپزخانه",
|
||||||
|
"groupDelivery": "تحویل و پیک",
|
||||||
|
"groupCustomers": "مشتریان",
|
||||||
|
"groupCoupons": "کوپنها",
|
||||||
|
"groupMarketing": "بازاریابی و نظرات",
|
||||||
"groupReports": "گزارش و مالی",
|
"groupReports": "گزارش و مالی",
|
||||||
"groupOps": "عملیات صندوق",
|
"groupExpenses": "هزینهها",
|
||||||
"groupKitchen": "آشپزخانه و تحویل",
|
|
||||||
"perm": {
|
"perm": {
|
||||||
"ManageCafeSettings": "تنظیمات کافه",
|
"ViewCafeSettings": "مشاهده تنظیمات کافه",
|
||||||
"ManageBilling": "اشتراک و پرداخت",
|
"ManageCafeSettings": "ویرایش تنظیمات کافه",
|
||||||
"ManageBranches": "مدیریت شعب",
|
"ManageDiscoverProfile": "پروفایل عمومی و کوجا",
|
||||||
"ManageMenu": "مدیریت منو",
|
"ViewBilling": "مشاهده صورتحساب",
|
||||||
"ManageInventory": "انبار و موجودی",
|
"ManageBilling": "مدیریت اشتراک و پرداخت",
|
||||||
"ManageTaxes": "مالیات",
|
"ManageRoles": "مدیریت نقشها",
|
||||||
"ManagePrintSettings": "تنظیمات چاپ",
|
"ViewPrintSettings": "مشاهده تنظیمات چاپ",
|
||||||
"ManageStaff": "مدیریت کارمندان",
|
"ManagePrintSettings": "ویرایش تنظیمات چاپ",
|
||||||
"ManageSalaries": "حقوق و دستمزد",
|
"ViewBranches": "مشاهده شعب",
|
||||||
"ReviewLeave": "بررسی مرخصی",
|
"CreateBranch": "ایجاد شعبه",
|
||||||
"ManageReservations": "رزروها",
|
"EditBranch": "ویرایش شعبه",
|
||||||
"ManageTables": "میزها",
|
"DeleteBranch": "حذف شعبه",
|
||||||
"ManageCoupons": "کوپنها",
|
"ViewMenu": "مشاهده منو",
|
||||||
"ViewReports": "گزارشها",
|
"CreateMenuItem": "افزودن آیتم منو",
|
||||||
"ManageExpenses": "هزینهها",
|
"EditMenuItem": "ویرایش آیتم منو",
|
||||||
|
"DeleteMenuItem": "حذف آیتم منو",
|
||||||
|
"ViewInventory": "مشاهده انبار",
|
||||||
|
"CreateInventory": "افزودن به انبار",
|
||||||
|
"EditInventory": "ویرایش انبار و موجودی",
|
||||||
|
"DeleteInventory": "حذف از انبار",
|
||||||
|
"ViewTaxes": "مشاهده مالیات",
|
||||||
|
"CreateTax": "ایجاد مالیات",
|
||||||
|
"EditTax": "ویرایش مالیات",
|
||||||
|
"DeleteTax": "حذف مالیات",
|
||||||
|
"ViewStaff": "مشاهده کارمندان",
|
||||||
|
"CreateStaff": "افزودن کارمند",
|
||||||
|
"EditStaff": "ویرایش کارمند",
|
||||||
|
"DeleteStaff": "حذف کارمند",
|
||||||
|
"ManageStaff": "تخصیص نقش شعبه",
|
||||||
|
"ManageStaffCredentials": "مدیریت اطلاعات ورود",
|
||||||
|
"ViewAttendance": "مشاهده حضور و غیاب",
|
||||||
|
"ManageAttendance": "مدیریت حضور و غیاب",
|
||||||
|
"ViewSchedules": "مشاهده شیفتها",
|
||||||
|
"ManageSchedules": "مدیریت شیفتها",
|
||||||
|
"ViewLeave": "مشاهده درخواست مرخصی",
|
||||||
|
"ReviewLeave": "تأیید مرخصی",
|
||||||
|
"ViewSalaries": "مشاهده حقوق",
|
||||||
|
"ManageSalaries": "مدیریت حقوق و دستمزد",
|
||||||
|
"ViewTables": "مشاهده میزها",
|
||||||
|
"ManageTables": "مدیریت میز و بخشها",
|
||||||
|
"ViewReservations": "مشاهده رزروها",
|
||||||
|
"CreateReservation": "ایجاد رزرو",
|
||||||
|
"EditReservation": "ویرایش رزرو",
|
||||||
|
"DeleteReservation": "حذف رزرو",
|
||||||
|
"ViewOrders": "مشاهده سفارشها",
|
||||||
"ProcessOrders": "ثبت سفارش",
|
"ProcessOrders": "ثبت سفارش",
|
||||||
"HandlePayments": "پردازش پرداخت",
|
"EditOrder": "ویرایش سفارش",
|
||||||
"OperateRegister": "صندوق",
|
"VoidOrder": "ابطال / لغو سفارش",
|
||||||
"ManageQueue": "صف انتظار",
|
"RefundOrder": "استرداد وجه سفارش",
|
||||||
|
"ApplyDiscount": "اعمال تخفیف",
|
||||||
|
"CompOrder": "سفارش رایگان (مهمان)",
|
||||||
|
"HandlePayments": "دریافت پرداخت",
|
||||||
|
"UpdateOrderStatus": "تغییر وضعیت سفارش",
|
||||||
|
"OperateRegister": "باز / بستن صندوق",
|
||||||
|
"OpenCashDrawer": "باز کردن کشوی پول (بدون فروش)",
|
||||||
|
"ViewQueue": "مشاهده صف",
|
||||||
|
"ManageQueue": "مدیریت صف",
|
||||||
"ViewKitchen": "نمایش آشپزخانه",
|
"ViewKitchen": "نمایش آشپزخانه",
|
||||||
"HandleDelivery": "تحویل و پیک"
|
"ManageKitchenStations": "مدیریت ایستگاههای آشپزخانه",
|
||||||
|
"ViewDelivery": "مشاهده تحویل",
|
||||||
|
"HandleDelivery": "مدیریت تحویل",
|
||||||
|
"AssignDelivery": "تخصیص پیک",
|
||||||
|
"ViewCustomers": "مشاهده مشتریان",
|
||||||
|
"CreateCustomer": "افزودن مشتری",
|
||||||
|
"EditCustomer": "ویرایش مشتری",
|
||||||
|
"DeleteCustomer": "حذف مشتری",
|
||||||
|
"ViewCoupons": "مشاهده کوپنها",
|
||||||
|
"CreateCoupon": "ایجاد کوپن",
|
||||||
|
"EditCoupon": "ویرایش کوپن",
|
||||||
|
"DeleteCoupon": "حذف کوپن",
|
||||||
|
"ViewSms": "مشاهده پیامک",
|
||||||
|
"SendSms": "ارسال کمپین پیامکی",
|
||||||
|
"ManageSmsSettings": "تنظیمات پیامک",
|
||||||
|
"ViewReviews": "مشاهده نظرات",
|
||||||
|
"ManageReviews": "پاسخ و مدیریت نظرات",
|
||||||
|
"ViewReports": "مشاهده گزارشها",
|
||||||
|
"ExportReports": "خروجی گرفتن از گزارش",
|
||||||
|
"ViewAuditLog": "مشاهده گزارش رویدادها",
|
||||||
|
"ViewFinancials": "مشاهده مالی (سود و زیان)",
|
||||||
|
"ManageFinancials": "اصلاح سند پرداخت",
|
||||||
|
"ViewExpenses": "مشاهده هزینهها",
|
||||||
|
"CreateExpense": "افزودن هزینه",
|
||||||
|
"EditExpense": "ویرایش هزینه",
|
||||||
|
"DeleteExpense": "حذف هزینه"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { useAuthStore } from "@/lib/stores/auth.store";
|
|||||||
import { useOfflineSync } from "@/lib/offline/use-offline-sync";
|
import { useOfflineSync } from "@/lib/offline/use-offline-sync";
|
||||||
import { useOrderAlerts } from "@/lib/realtime/use-order-alerts";
|
import { useOrderAlerts } from "@/lib/realtime/use-order-alerts";
|
||||||
import { useTabBadge } from "@/lib/notifications/use-tab-badge";
|
import { useTabBadge } from "@/lib/notifications/use-tab-badge";
|
||||||
|
import { useNotificationNavBridge } from "@/lib/notifications/notification-routes";
|
||||||
|
import { NotificationPermissionPrompt } from "@/components/notifications/notification-permission-prompt";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -24,6 +26,7 @@ export default function DashboardLayout({
|
|||||||
useOfflineSync(); // register online/offline listeners + load queue count
|
useOfflineSync(); // register online/offline listeners + load queue count
|
||||||
useOrderAlerts(); // global sound + toast + desktop popup for café notifications
|
useOrderAlerts(); // global sound + toast + desktop popup for café notifications
|
||||||
useTabBadge(); // unread count on the browser tab title + favicon
|
useTabBadge(); // unread count on the browser tab title + favicon
|
||||||
|
useNotificationNavBridge(); // toast/desktop notification clicks → navigate
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wait for Zustand to finish reading localStorage before deciding to redirect.
|
// Wait for Zustand to finish reading localStorage before deciding to redirect.
|
||||||
@@ -63,6 +66,7 @@ export default function DashboardLayout({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<NotificationPermissionPrompt />
|
||||||
</CafeThemeProvider>
|
</CafeThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { RecentOrdersScreen } from "@/components/orders/recent-orders-screen";
|
||||||
|
|
||||||
|
/** Recent orders — browse closed orders and reprint the customer receipt and the
|
||||||
|
* kitchen / bar tickets per order. */
|
||||||
|
export default function OrdersPage() {
|
||||||
|
return <RecentOrdersScreen />;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { Loader2 } from "lucide-react";
|
|||||||
import { useRouter } from "@/i18n/routing";
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { RouteGuard } from "@/components/auth/route-guard";
|
import { RouteGuard } from "@/components/auth/route-guard";
|
||||||
|
import { useOrderAlerts } from "@/lib/realtime/use-order-alerts";
|
||||||
|
import { NotificationPermissionPrompt } from "@/components/notifications/notification-permission-prompt";
|
||||||
|
|
||||||
/** Full-viewport routes (POS, queue TV display) — auth only, no dashboard chrome. */
|
/** Full-viewport routes (POS, queue TV display) — auth only, no dashboard chrome. */
|
||||||
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
|
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -15,6 +17,10 @@ export default function FullscreenLayout({ children }: { children: React.ReactNo
|
|||||||
const hasHydrated = useAuthStore((s) => s._hasHydrated);
|
const hasHydrated = useAuthStore((s) => s._hasHydrated);
|
||||||
const dir = locale === "en" ? "ltr" : "rtl";
|
const dir = locale === "en" ? "ltr" : "rtl";
|
||||||
|
|
||||||
|
// Surface café notifications (waiter calls, new guest orders) on the POS /
|
||||||
|
// queue-display too — during service staff are here, not on the dashboard.
|
||||||
|
useOrderAlerts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only redirect AFTER the persisted auth has rehydrated from localStorage —
|
// Only redirect AFTER the persisted auth has rehydrated from localStorage —
|
||||||
// otherwise a page refresh sees the empty initial state and bounces an
|
// otherwise a page refresh sees the empty initial state and bounces an
|
||||||
@@ -36,6 +42,7 @@ export default function FullscreenLayout({ children }: { children: React.ReactNo
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-svh" dir={dir}>
|
<div className="min-h-svh" dir={dir}>
|
||||||
<RouteGuard>{children}</RouteGuard>
|
<RouteGuard>{children}</RouteGuard>
|
||||||
|
<NotificationPermissionPrompt />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useLocale } from "next-intl";
|
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
|
||||||
import { Topbar } from "@/components/layout/topbar";
|
|
||||||
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classic POS route layout — wraps the terminal in the standard dashboard
|
|
||||||
* chrome (collapsible sidebar + topbar) but keeps the main content area
|
|
||||||
* overflow-hidden so PosScreen can manage its own internal scrolling.
|
|
||||||
*/
|
|
||||||
export default function PosClassicLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const locale = useLocale();
|
|
||||||
const isRtl = locale !== "en";
|
|
||||||
|
|
||||||
const mainColumn = (
|
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
||||||
<Topbar />
|
|
||||||
<main className="min-h-0 flex-1 overflow-hidden bg-background p-3 md:p-4">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CafeThemeProvider>
|
|
||||||
<div
|
|
||||||
className="flex h-screen min-h-0 overflow-hidden bg-background"
|
|
||||||
dir={isRtl ? "rtl" : "ltr"}
|
|
||||||
>
|
|
||||||
{isRtl ? (
|
|
||||||
<>
|
|
||||||
<Sidebar side="right" />
|
|
||||||
{mainColumn}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sidebar side="left" />
|
|
||||||
{mainColumn}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CafeThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import { PosScreen } from "@/components/pos/pos-screen";
|
|
||||||
|
|
||||||
/** Classic POS terminal — chrome (sidebar + topbar) is provided by layout.tsx.
|
|
||||||
* Kept as a fallback while POS v2 (at /pos) is piloted. */
|
|
||||||
export default function PosClassicPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PosScreen />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
|
|||||||
* POS v2 layout — the redesigned terminal is full-screen (its own topbar +
|
* POS v2 layout — the redesigned terminal is full-screen (its own topbar +
|
||||||
* order ticket), so no dashboard sidebar/topbar chrome here. Café theming
|
* order ticket), so no dashboard sidebar/topbar chrome here. Café theming
|
||||||
* still applies. Auth guarding comes from the parent (fullscreen) layout.
|
* still applies. Auth guarding comes from the parent (fullscreen) layout.
|
||||||
* The classic POS keeps its chrome under /pos-classic.
|
|
||||||
*/
|
*/
|
||||||
export default function PosLayout({ children }: { children: React.ReactNode }) {
|
export default function PosLayout({ children }: { children: React.ReactNode }) {
|
||||||
return <CafeThemeProvider>{children}</CafeThemeProvider>;
|
return <CafeThemeProvider>{children}</CafeThemeProvider>;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Pos2Screen } from "@/components/pos2/pos2-screen";
|
import { Pos2Screen } from "@/components/pos2/pos2-screen";
|
||||||
|
|
||||||
/** Default POS terminal — redesigned v2, wired to live data (menu, tables,
|
/** POS terminal — wired to live data (menu, tables, orders, payments) via the
|
||||||
* orders, payments) via the shared cart store + offline submit pipeline.
|
* shared cart store + offline submit pipeline. */
|
||||||
* The classic POS remains available at /[locale]/pos-classic. */
|
|
||||||
export default function PosPage() {
|
export default function PosPage() {
|
||||||
return <Pos2Screen />;
|
return <Pos2Screen />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter, Link } from "@/i18n/routing";
|
import { useRouter, Link } from "@/i18n/routing";
|
||||||
import { apiPost, ApiClientError } from "@/lib/api/client";
|
import { apiPost, ApiClientError } from "@/lib/api/client";
|
||||||
@@ -18,6 +18,15 @@ export default function LoginPage() {
|
|||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const hasHydrated = useAuthStore((s) => s._hasHydrated);
|
||||||
|
|
||||||
|
// Already signed in? Don't show the login form again — send them to the app.
|
||||||
|
// Gate on _hasHydrated so we don't act on a not-yet-rehydrated (null) session.
|
||||||
|
const alreadyAuthed = hasHydrated && !!user?.accessToken;
|
||||||
|
useEffect(() => {
|
||||||
|
if (alreadyAuthed) router.replace("/pos");
|
||||||
|
}, [alreadyAuthed, router]);
|
||||||
|
|
||||||
const [tab, setTab] = useState<LoginTab>("otp");
|
const [tab, setTab] = useState<LoginTab>("otp");
|
||||||
|
|
||||||
@@ -140,6 +149,14 @@ export default function LoginPage() {
|
|||||||
setCode("");
|
setCode("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (alreadyAuthed) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("redirecting")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user