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

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:
soroush.asadi
2026-06-25 12:02:25 +03:30
parent 67450393fc
commit cb57c61a11
12 changed files with 4369 additions and 0 deletions
@@ -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));
}
}