feat(print): cloud↔local print-agent foundation (hub, pairing, registry)
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
First phase of auto-discovered printing for cloud-hosted cafés whose printers are
on the local network (the cloud can't reach a LAN/USB printer directly). Adds:
- PrintAgent + PrintDevice entities (+ additive migration) — a per-café local
bridge and the printers it reports.
- PrintAgentHub (/hubs/print-agent): agents connect outbound, authenticated by a
token in access_token (not the user JWT); ReportPrinters upserts devices,
PrintJob is pushed to the agent, JobResult/Heartbeat come back.
- PrintAgentRegistry (singleton): tracks connected agents and dispatches a job to
one, awaiting its ack with a timeout.
- Pairing: POST /cafes/{id}/print-agents/pairing-code (ManagePrintSettings) issues
a short one-time code; anonymous POST /print-agent/claim redeems it for a
long-lived token (only its SHA-256 hash is stored). List + revoke endpoints,
online status from the registry.
Inert until Phase 2 routes jobs through it and the agent app (Phase 3) connects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,118 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public PrintAgentsController(AppDbContext db, IPrintAgentRegistry registry)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_registry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ 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.AddHttpClient(nameof(PosDeviceService));
|
services.AddHttpClient(nameof(PosDeviceService));
|
||||||
services.AddScoped<IPosDeviceService, PosDeviceService>();
|
services.AddScoped<IPosDeviceService, PosDeviceService>();
|
||||||
services.AddScoped<SubscriptionRenewalReminderJob>();
|
services.AddScoped<SubscriptionRenewalReminderJob>();
|
||||||
@@ -224,6 +225,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,111 @@
|
|||||||
|
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: 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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>
|
||||||
|
/// 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();
|
||||||
|
Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default);
|
||||||
|
void CompleteJob(string jobId, bool success, string? error);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!;
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
@@ -459,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);
|
||||||
|
|||||||
+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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1935,6 +1935,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")
|
||||||
@@ -3150,6 +3248,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")
|
||||||
@@ -3473,6 +3600,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");
|
||||||
|
|||||||
Reference in New Issue
Block a user