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,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"))
+111
View File
@@ -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));
}
}
+29
View File
@@ -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; } = [];
}
+18
View File
@@ -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);
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");