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<ReceiptBuilder>();
|
||||
services.AddScoped<IPrinterService, NetworkPrinterService>();
|
||||
services.AddSingleton<IPrintAgentRegistry, PrintAgentRegistry>();
|
||||
services.AddHttpClient(nameof(PosDeviceService));
|
||||
services.AddScoped<IPosDeviceService, PosDeviceService>();
|
||||
services.AddScoped<SubscriptionRenewalReminderJob>();
|
||||
@@ -224,6 +225,7 @@ public static class ServiceCollectionExtensions
|
||||
app.MapControllers();
|
||||
app.MapHub<KdsHub>("/hubs/kds");
|
||||
app.MapHub<GuestOrderHub>("/hubs/guest-order");
|
||||
app.MapHub<PrintAgentHub>("/hubs/print-agent");
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
|
||||
|
||||
if (!app.Configuration.GetValue<bool>("Testing:Enabled"))
|
||||
|
||||
@@ -0,0 +1,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<ConsumerAccount> ConsumerAccounts => Set<ConsumerAccount>();
|
||||
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<Ingredient> Ingredients => Set<Ingredient>();
|
||||
public DbSet<MenuItemIngredient> MenuItemIngredients => Set<MenuItemIngredient>();
|
||||
@@ -459,6 +461,32 @@ public class AppDbContext : DbContext
|
||||
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 =>
|
||||
{
|
||||
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");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -3150,6 +3248,35 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
|
||||
@@ -3473,6 +3600,11 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.PrintAgent", b =>
|
||||
{
|
||||
b.Navigation("Devices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.Shift", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
|
||||
Reference in New Issue
Block a user