Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b468b48d9 | |||
| f4583f5169 | |||
| 132f0921e0 |
@@ -215,6 +215,9 @@ public static class ServiceCollectionExtensions
|
|||||||
app.UseMeeziSecurity();
|
app.UseMeeziSecurity();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseMiddleware<Middleware.TenantMiddleware>();
|
app.UseMiddleware<Middleware.TenantMiddleware>();
|
||||||
|
// After tenant context (keys are scoped per café), before plan-limit + controllers
|
||||||
|
// so a replayed write short-circuits without re-consuming limits or re-executing.
|
||||||
|
app.UseMiddleware<Middleware.IdempotencyMiddleware>();
|
||||||
app.UseMiddleware<Middleware.PlanLimitMiddleware>();
|
app.UseMiddleware<Middleware.PlanLimitMiddleware>();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Meezi.Core.Entities;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Meezi.API.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Makes mutating requests safe to retry. A client (e.g. the offline outbox)
|
||||||
|
/// attaches an <c>Idempotency-Key</c> header; if the same key is seen again, the
|
||||||
|
/// original response is replayed instead of executing the write twice.
|
||||||
|
///
|
||||||
|
/// Bookkeeping runs in isolated DI scopes so it never mixes with the controller's
|
||||||
|
/// own DbContext unit of work. Opt-in via header → non-idempotent and binary/file
|
||||||
|
/// endpoints are unaffected unless the client explicitly sends a key.
|
||||||
|
/// </summary>
|
||||||
|
public class IdempotencyMiddleware
|
||||||
|
{
|
||||||
|
private const string HeaderName = "Idempotency-Key";
|
||||||
|
private const int MaxKeyLength = 200;
|
||||||
|
private const int MaxStoredBodyBytes = 256 * 1024;
|
||||||
|
/// <summary>An InProgress record older than this is assumed crashed mid-flight and re-run.</summary>
|
||||||
|
private static readonly TimeSpan StaleInProgress = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<IdempotencyMiddleware> _logger;
|
||||||
|
|
||||||
|
public IdempotencyMiddleware(RequestDelegate next, ILogger<IdempotencyMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context, ITenantContext tenant, IServiceScopeFactory scopeFactory)
|
||||||
|
{
|
||||||
|
var method = context.Request.Method;
|
||||||
|
var isMutating = HttpMethods.IsPost(method) || HttpMethods.IsPut(method)
|
||||||
|
|| HttpMethods.IsPatch(method) || HttpMethods.IsDelete(method);
|
||||||
|
|
||||||
|
if (!isMutating || !context.Request.Headers.TryGetValue(HeaderName, out var headerValues))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = headerValues.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(key) || key.Length > MaxKeyLength)
|
||||||
|
{
|
||||||
|
// Unusable key — behave as if it wasn't sent rather than reject the write.
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scope = string.IsNullOrEmpty(tenant.CafeId) ? "global" : tenant.CafeId;
|
||||||
|
var path = context.Request.Path.Value ?? string.Empty;
|
||||||
|
|
||||||
|
// 1) Look for an existing record for this (tenant, key).
|
||||||
|
await using (var lookupScope = scopeFactory.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = lookupScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var existing = await db.IdempotencyRecords.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(r => r.Scope == scope && r.Key == key, context.RequestAborted);
|
||||||
|
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
if (existing.Status == IdempotencyStatus.Completed)
|
||||||
|
{
|
||||||
|
await ReplayAsync(context, existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (DateTime.UtcNow - existing.CreatedAt < StaleInProgress)
|
||||||
|
{
|
||||||
|
await WriteConflictAsync(context); // genuine concurrent duplicate
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Stale reservation (process likely crashed mid-flight) — drop and re-run.
|
||||||
|
_logger.LogWarning("Recovering stale idempotency reservation {Key} for scope {Scope}", key, scope);
|
||||||
|
var stale = await db.IdempotencyRecords
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == existing.Id, context.RequestAborted);
|
||||||
|
if (stale is not null)
|
||||||
|
{
|
||||||
|
db.IdempotencyRecords.Remove(stale);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Reserve the key. The unique (Scope, Key) index serializes racing first requests.
|
||||||
|
var record = new IdempotencyRecord
|
||||||
|
{
|
||||||
|
Scope = scope,
|
||||||
|
Key = key,
|
||||||
|
Method = method,
|
||||||
|
Path = path,
|
||||||
|
Status = IdempotencyStatus.InProgress,
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var reserveScope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = reserveScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
db.IdempotencyRecords.Add(record);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
await WriteConflictAsync(context); // another request won the reservation race
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Run the real request, capturing its response.
|
||||||
|
var originalBody = context.Response.Body;
|
||||||
|
await using var buffer = new MemoryStream();
|
||||||
|
context.Response.Body = buffer;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
context.Response.Body = originalBody;
|
||||||
|
await DeleteAsync(scopeFactory, record.Id);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusCode = context.Response.StatusCode;
|
||||||
|
buffer.Position = 0;
|
||||||
|
var bytes = buffer.ToArray();
|
||||||
|
context.Response.Body = originalBody;
|
||||||
|
if (bytes.Length > 0)
|
||||||
|
await originalBody.WriteAsync(bytes, context.RequestAborted);
|
||||||
|
|
||||||
|
// 4) Persist the result so retries replay it — except 5xx, which is transient and
|
||||||
|
// released so the client can retry the same key.
|
||||||
|
if (statusCode is >= 200 and < 500)
|
||||||
|
{
|
||||||
|
var storedBody = bytes.Length is > 0 and <= MaxStoredBodyBytes
|
||||||
|
? Encoding.UTF8.GetString(bytes)
|
||||||
|
: null;
|
||||||
|
await CompleteAsync(scopeFactory, record.Id, statusCode, storedBody);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await DeleteAsync(scopeFactory, record.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ReplayAsync(HttpContext context, IdempotencyRecord record)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = record.ResponseStatusCode;
|
||||||
|
context.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
context.Response.Headers["Idempotent-Replay"] = "true";
|
||||||
|
if (!string.IsNullOrEmpty(record.ResponseBody))
|
||||||
|
await context.Response.WriteAsync(record.ResponseBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteConflictAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||||
|
context.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
await context.Response.WriteAsync(
|
||||||
|
"{\"success\":false,\"data\":null,\"error\":{\"code\":\"IDEMPOTENCY_IN_PROGRESS\",\"message\":\"A request with this key is still being processed.\"}}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CompleteAsync(IServiceScopeFactory f, string id, int status, string? body)
|
||||||
|
{
|
||||||
|
await using var s = f.CreateAsyncScope();
|
||||||
|
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
if (rec is null) return;
|
||||||
|
rec.Status = IdempotencyStatus.Completed;
|
||||||
|
rec.ResponseStatusCode = status;
|
||||||
|
rec.ResponseBody = body;
|
||||||
|
rec.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DeleteAsync(IServiceScopeFactory f, string id)
|
||||||
|
{
|
||||||
|
await using var s = f.CreateAsyncScope();
|
||||||
|
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
if (rec is null) return;
|
||||||
|
db.IdempotencyRecords.Remove(rec);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Meezi.Core.Enums;
|
||||||
|
|
||||||
|
namespace Meezi.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a client-supplied Idempotency-Key so a retried write (e.g. an order
|
||||||
|
/// replayed from the offline outbox after a lost response) returns the original
|
||||||
|
/// result instead of executing twice. Standalone POCO — deliberately not a
|
||||||
|
/// TenantEntity, to avoid soft-delete/tenant query filters.
|
||||||
|
/// </summary>
|
||||||
|
public class IdempotencyRecord
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
/// <summary>Tenant scope (CafeId), or "global" for non-tenant requests.</summary>
|
||||||
|
public string Scope { get; set; } = "global";
|
||||||
|
|
||||||
|
/// <summary>The client-supplied Idempotency-Key header value.</summary>
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Method { get; set; } = string.Empty;
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public IdempotencyStatus Status { get; set; } = IdempotencyStatus.InProgress;
|
||||||
|
|
||||||
|
public int ResponseStatusCode { get; set; }
|
||||||
|
public string? ResponseBody { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Meezi.Core.Enums;
|
||||||
|
|
||||||
|
public enum IdempotencyStatus
|
||||||
|
{
|
||||||
|
/// <summary>Reserved; the original request is still executing.</summary>
|
||||||
|
InProgress = 0,
|
||||||
|
/// <summary>Finished; the stored response is replayed on duplicate keys.</summary>
|
||||||
|
Completed = 1
|
||||||
|
}
|
||||||
@@ -82,10 +82,25 @@ public class AppDbContext : DbContext
|
|||||||
// Immutable audit trail of sensitive POS / management actions.
|
// Immutable audit trail of sensitive POS / management actions.
|
||||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||||
|
|
||||||
|
// Idempotency keys for safe retry of offline-replayed writes.
|
||||||
|
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<IdempotencyRecord>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
// One result per (tenant, key). The unique index also serializes
|
||||||
|
// concurrent first-time requests carrying the same key.
|
||||||
|
e.HasIndex(x => new { x.Scope, x.Key }).IsUnique();
|
||||||
|
e.Property(x => x.Scope).HasMaxLength(64).IsRequired();
|
||||||
|
e.Property(x => x.Key).HasMaxLength(200).IsRequired();
|
||||||
|
e.Property(x => x.Method).HasMaxLength(10).IsRequired();
|
||||||
|
e.Property(x => x.Path).HasMaxLength(512).IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<PushDevice>(e =>
|
modelBuilder.Entity<PushDevice>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
|
|||||||
+3364
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddIdempotencyRecords : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "IdempotencyRecords",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Scope = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
Key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Method = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||||
|
Path = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||||
|
Status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ResponseStatusCode = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ResponseBody = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_IdempotencyRecords", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_IdempotencyRecords_Scope_Key",
|
||||||
|
table: "IdempotencyRecords",
|
||||||
|
columns: new[] { "Scope", "Key" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "IdempotencyRecords");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1129,6 +1129,54 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("Expenses");
|
b.ToTable("Expenses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.IdempotencyRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Method")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseBody")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("ResponseStatusCode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Scope")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Scope", "Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("IdempotencyRecords");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Meezi.API.Middleware;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
|
public class IdempotencyMiddlewareTests
|
||||||
|
{
|
||||||
|
private sealed class TestTenant(string? cafeId) : ITenantContext
|
||||||
|
{
|
||||||
|
public string? UserId => "user-1";
|
||||||
|
public string? CafeId => cafeId;
|
||||||
|
public EmployeeRole? Role => EmployeeRole.Owner;
|
||||||
|
public PlanTier? PlanTier => Core.Enums.PlanTier.Pro;
|
||||||
|
public string? Language => "fa";
|
||||||
|
public string? BranchId => null;
|
||||||
|
public bool IsSystemAdmin => false;
|
||||||
|
public bool IsAuthenticated => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A scope factory whose scopes share one in-memory database, mirroring how the
|
||||||
|
/// middleware opens isolated DI scopes against the same store in production.</summary>
|
||||||
|
private static IServiceScopeFactory BuildScopeFactory()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase(dbName));
|
||||||
|
services.AddLogging();
|
||||||
|
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DefaultHttpContext NewPost(string? key)
|
||||||
|
{
|
||||||
|
var ctx = new DefaultHttpContext();
|
||||||
|
ctx.Request.Method = "POST";
|
||||||
|
ctx.Request.Path = "/api/test";
|
||||||
|
if (key is not null) ctx.Request.Headers["Idempotency-Key"] = key;
|
||||||
|
ctx.Response.Body = new MemoryStream();
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadBody(HttpContext ctx)
|
||||||
|
{
|
||||||
|
ctx.Response.Body.Position = 0;
|
||||||
|
return new StreamReader(ctx.Response.Body).ReadToEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_ExecutesOnce_AndReplaysStoredResponse()
|
||||||
|
{
|
||||||
|
var scopeFactory = BuildScopeFactory();
|
||||||
|
var tenant = new TestTenant("cafe-1");
|
||||||
|
var calls = 0;
|
||||||
|
RequestDelegate next = async ctx =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
await ctx.Response.WriteAsync($"{{\"v\":\"{Guid.NewGuid():N}\"}}");
|
||||||
|
};
|
||||||
|
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||||
|
|
||||||
|
var c1 = NewPost("KEY-1");
|
||||||
|
await mw.InvokeAsync(c1, tenant, scopeFactory);
|
||||||
|
var body1 = ReadBody(c1);
|
||||||
|
|
||||||
|
var c2 = NewPost("KEY-1");
|
||||||
|
await mw.InvokeAsync(c2, tenant, scopeFactory);
|
||||||
|
var body2 = ReadBody(c2);
|
||||||
|
|
||||||
|
Assert.Equal(1, calls); // executed exactly once
|
||||||
|
Assert.Equal(body1, body2); // second call replays the stored body verbatim
|
||||||
|
Assert.Equal(200, c2.Response.StatusCode);
|
||||||
|
Assert.Equal("true", c2.Response.Headers["Idempotent-Replay"].ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DifferentKey_ExecutesAgain()
|
||||||
|
{
|
||||||
|
var scopeFactory = BuildScopeFactory();
|
||||||
|
var tenant = new TestTenant("cafe-1");
|
||||||
|
var calls = 0;
|
||||||
|
RequestDelegate next = async ctx =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
await ctx.Response.WriteAsync("{\"ok\":true}");
|
||||||
|
};
|
||||||
|
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(NewPost("A"), tenant, scopeFactory);
|
||||||
|
await mw.InvokeAsync(NewPost("B"), tenant, scopeFactory);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NoKey_PassesThrough_NoIdempotency()
|
||||||
|
{
|
||||||
|
var scopeFactory = BuildScopeFactory();
|
||||||
|
var tenant = new TestTenant("cafe-1");
|
||||||
|
var calls = 0;
|
||||||
|
RequestDelegate next = ctx =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(NewPost(null), tenant, scopeFactory);
|
||||||
|
await mw.InvokeAsync(NewPost(null), tenant, scopeFactory);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_DifferentTenant_IsNotReplayed()
|
||||||
|
{
|
||||||
|
var scopeFactory = BuildScopeFactory();
|
||||||
|
var calls = 0;
|
||||||
|
RequestDelegate next = async ctx =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
await ctx.Response.WriteAsync("{\"ok\":true}");
|
||||||
|
};
|
||||||
|
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(NewPost("SHARED"), new TestTenant("cafe-A"), scopeFactory);
|
||||||
|
await mw.InvokeAsync(NewPost("SHARED"), new TestTenant("cafe-B"), scopeFactory);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls); // keys are scoped per café — no cross-tenant collision
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerError_IsNotCached_SoRetryReexecutes()
|
||||||
|
{
|
||||||
|
var scopeFactory = BuildScopeFactory();
|
||||||
|
var tenant = new TestTenant("cafe-1");
|
||||||
|
var calls = 0;
|
||||||
|
RequestDelegate next = async ctx =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
ctx.Response.StatusCode = 500;
|
||||||
|
await ctx.Response.WriteAsync("{\"error\":\"boom\"}");
|
||||||
|
};
|
||||||
|
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(NewPost("KEY-5XX"), tenant, scopeFactory);
|
||||||
|
var c2 = NewPost("KEY-5XX");
|
||||||
|
await mw.InvokeAsync(c2, tenant, scopeFactory);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls); // 5xx is transient → reservation released, retry runs again
|
||||||
|
Assert.NotEqual("true", c2.Response.Headers["Idempotent-Replay"].ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ConfirmProvider } from "@/components/providers/confirm-provider";
|
import { ConfirmProvider } from "@/components/providers/confirm-provider";
|
||||||
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
||||||
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
|
import { restoreQueryCache, startPersisting } from "@/lib/offline/query-persister";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: { staleTime: 30_000, retry: 1 },
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
// Keep data in memory long enough to back offline reads; it is also
|
||||||
|
// persisted to IndexedDB by the persister below.
|
||||||
|
gcTime: 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Persist the query cache to IndexedDB so the dashboard is viewable offline.
|
||||||
|
// Scoped to the current café so a different tenant never hydrates this data.
|
||||||
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
|
useEffect(() => {
|
||||||
|
const scope = cafeId ?? "anon";
|
||||||
|
let active = true;
|
||||||
|
let stop: () => void = () => {};
|
||||||
|
void (async () => {
|
||||||
|
await restoreQueryCache(queryClient, scope);
|
||||||
|
if (!active) return;
|
||||||
|
stop = startPersisting(queryClient, scope);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [queryClient, cafeId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ api.interceptors.response.use(
|
|||||||
|
|
||||||
const apiError = error.response?.data?.error;
|
const apiError = error.response?.data?.error;
|
||||||
if (apiError?.code) {
|
if (apiError?.code) {
|
||||||
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
|
return Promise.reject(new ApiClientError(apiError.code, apiError.message, undefined, status));
|
||||||
}
|
}
|
||||||
if (status === 401 && typeof window !== "undefined") {
|
if (status === 401 && typeof window !== "undefined") {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
@@ -131,15 +131,29 @@ export class ApiClientError extends Error {
|
|||||||
public readonly code: string,
|
public readonly code: string,
|
||||||
message: string,
|
message: string,
|
||||||
/** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */
|
/** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */
|
||||||
public readonly payload?: unknown
|
public readonly payload?: unknown,
|
||||||
|
/** HTTP status, when known — lets callers (e.g. the outbox) tell 5xx (retry) from 4xx (give up). */
|
||||||
|
public readonly status?: number
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ApiClientError";
|
this.name = "ApiClientError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T> {
|
/** Options for mutating requests. An `idempotencyKey` is sent as the
|
||||||
const { data } = await api.post<ApiResponse<T>>(url, body);
|
* `Idempotency-Key` header so the server safely de-duplicates retries
|
||||||
|
* (used by the offline outbox; harmless when omitted). */
|
||||||
|
export interface WriteOptions {
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfig(opts?: WriteOptions) {
|
||||||
|
if (!opts?.idempotencyKey) return undefined;
|
||||||
|
return { headers: { "Idempotency-Key": opts.idempotencyKey } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||||
|
const { data } = await api.post<ApiResponse<T>>(url, body, writeConfig(opts));
|
||||||
if (!data.success || data.data === undefined) {
|
if (!data.success || data.data === undefined) {
|
||||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||||
throw new ApiClientError(code, data.error?.message ?? "Request failed", data.data);
|
throw new ApiClientError(code, data.error?.message ?? "Request failed", data.data);
|
||||||
@@ -147,8 +161,8 @@ export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T>
|
|||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T> {
|
export async function apiPut<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||||
const { data } = await api.put<ApiResponse<T>>(url, body);
|
const { data } = await api.put<ApiResponse<T>>(url, body, writeConfig(opts));
|
||||||
if (!data.success || data.data === undefined) {
|
if (!data.success || data.data === undefined) {
|
||||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||||
@@ -156,8 +170,8 @@ export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T>
|
|||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T> {
|
export async function apiPatch<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||||
const { data } = await api.patch<ApiResponse<T>>(url, body);
|
const { data } = await api.patch<ApiResponse<T>>(url, body, writeConfig(opts));
|
||||||
if (!data.success || data.data === undefined) {
|
if (!data.success || data.data === undefined) {
|
||||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||||
@@ -165,8 +179,8 @@ export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T
|
|||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDelete(url: string): Promise<void> {
|
export async function apiDelete(url: string, opts?: WriteOptions): Promise<void> {
|
||||||
const { data } = await api.delete<ApiResponse<unknown>>(url);
|
const { data } = await api.delete<ApiResponse<unknown>>(url, writeConfig(opts));
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ export type OfflineQueueItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DB_NAME = "meezi_pos_offline";
|
const DB_NAME = "meezi_pos_offline";
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 3;
|
||||||
|
/** Legacy POS-orders-only queue (kept for one-time migration into the outbox). */
|
||||||
const STORE = "order_queue";
|
const STORE = "order_queue";
|
||||||
|
/** Generic key-value store (used to persist the React Query cache for offline reads). */
|
||||||
|
const KV_STORE = "kv";
|
||||||
|
/** Generic write outbox: any mutating request, replayed with idempotency + id remap. */
|
||||||
|
const OUTBOX_STORE = "outbox";
|
||||||
|
|
||||||
let _db: IDBDatabase | null = null;
|
let _db: IDBDatabase | null = null;
|
||||||
|
|
||||||
@@ -36,6 +41,12 @@ function openDb(): Promise<IDBDatabase> {
|
|||||||
if (!db.objectStoreNames.contains(STORE)) {
|
if (!db.objectStoreNames.contains(STORE)) {
|
||||||
db.createObjectStore(STORE, { keyPath: "id" });
|
db.createObjectStore(STORE, { keyPath: "id" });
|
||||||
}
|
}
|
||||||
|
if (!db.objectStoreNames.contains(KV_STORE)) {
|
||||||
|
db.createObjectStore(KV_STORE);
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(OUTBOX_STORE)) {
|
||||||
|
db.createObjectStore(OUTBOX_STORE, { keyPath: "id" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
req.onsuccess = () => {
|
req.onsuccess = () => {
|
||||||
_db = req.result;
|
_db = req.result;
|
||||||
@@ -109,3 +120,159 @@ export async function markQueueItemFailed(id: string): Promise<void> {
|
|||||||
tx.onerror = () => reject(tx.error);
|
tx.onerror = () => reject(tx.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Generic key-value store (React Query cache persistence) ───────────────────
|
||||||
|
|
||||||
|
/** Store an arbitrary JSON-serializable value under a key. Never throws. */
|
||||||
|
export async function kvSet(key: string, value: unknown): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(KV_STORE, "readwrite");
|
||||||
|
tx.objectStore(KV_STORE).put(value, key);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// IndexedDB unavailable / quota exceeded / blocked — degrade silently.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a value previously stored with {@link kvSet}. Returns undefined on any failure. */
|
||||||
|
export async function kvGet<T>(key: string): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
return await new Promise<T | undefined>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(KV_STORE, "readonly");
|
||||||
|
const req = tx.objectStore(KV_STORE).get(key);
|
||||||
|
req.onsuccess = () => resolve(req.result as T | undefined);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a persisted value (e.g. on logout, to avoid leaking another user's cache). */
|
||||||
|
export async function kvDelete(key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(KV_STORE, "readwrite");
|
||||||
|
tx.objectStore(KV_STORE).delete(key);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Generic write outbox ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type OutboxMethod = "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
|
||||||
|
export type OutboxOp = {
|
||||||
|
/** Local op id (primary key). */
|
||||||
|
id: string;
|
||||||
|
/** Stable Idempotency-Key sent on every send attempt for this op. */
|
||||||
|
idempotencyKey: string;
|
||||||
|
method: OutboxMethod;
|
||||||
|
/** Request URL; may embed a local id (local_*) to be remapped after its creator syncs. */
|
||||||
|
url: string;
|
||||||
|
body?: unknown;
|
||||||
|
/** Coarse entity kind, for conflict policy + UI grouping (e.g. "order", "menu_item"). */
|
||||||
|
entityType: string;
|
||||||
|
/** The local id this op creates, if any — enables remapping later ops that reference it. */
|
||||||
|
createsClientId?: string;
|
||||||
|
/** Dotted path to the new server id in the response data (default "id"). */
|
||||||
|
idField?: string;
|
||||||
|
createdAt: number;
|
||||||
|
attempts: number;
|
||||||
|
status: "pending" | "failed";
|
||||||
|
lastError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function enqueueOutboxOp(
|
||||||
|
op: Omit<OutboxOp, "attempts" | "status">
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(OUTBOX_STORE, "readwrite");
|
||||||
|
tx.objectStore(OUTBOX_STORE).put({ ...op, attempts: 0, status: "pending" });
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All queued ops, oldest first (insertion / causal order). */
|
||||||
|
export async function getOutboxOps(): Promise<OutboxOp[]> {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
const ops = await new Promise<OutboxOp[]>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(OUTBOX_STORE, "readonly");
|
||||||
|
const req = tx.objectStore(OUTBOX_STORE).getAll();
|
||||||
|
req.onsuccess = () => resolve(req.result as OutboxOp[]);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
return ops.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOutboxCount(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
return await new Promise<number>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(OUTBOX_STORE, "readonly");
|
||||||
|
const req = tx.objectStore(OUTBOX_STORE).count();
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeOutboxOp(id: string): Promise<void> {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(OUTBOX_STORE, "readwrite");
|
||||||
|
tx.objectStore(OUTBOX_STORE).delete(id);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOutboxOp(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Pick<OutboxOp, "status" | "attempts" | "lastError">>
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(OUTBOX_STORE, "readwrite");
|
||||||
|
const store = tx.objectStore(OUTBOX_STORE);
|
||||||
|
const getReq = store.get(id);
|
||||||
|
getReq.onsuccess = () => {
|
||||||
|
const op = getReq.result as OutboxOp | undefined;
|
||||||
|
if (op) store.put({ ...op, ...patch });
|
||||||
|
};
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── client→server id map (persisted across reloads) ───────────────────────────
|
||||||
|
|
||||||
|
const ID_MAP_KEY = "outbox_id_map";
|
||||||
|
|
||||||
|
export async function getIdMap(): Promise<Record<string, string>> {
|
||||||
|
return (await kvGet<Record<string, string>>(ID_MAP_KEY)) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setIdMapEntry(clientId: string, serverId: string): Promise<void> {
|
||||||
|
const map = await getIdMap();
|
||||||
|
map[clientId] = serverId;
|
||||||
|
await kvSet(ID_MAP_KEY, map);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Generic offline write engine.
|
||||||
|
*
|
||||||
|
* Every offline write is recorded as an {@link OutboxOp} carrying a stable
|
||||||
|
* idempotency key. On reconnect the outbox is drained in causal (insertion)
|
||||||
|
* order:
|
||||||
|
* - local ids (local_*) created by earlier ops are remapped to their real
|
||||||
|
* server ids before an op that references them is sent;
|
||||||
|
* - each op is sent with its idempotency key, so a replay after a lost response
|
||||||
|
* is de-duplicated by the server instead of creating a duplicate;
|
||||||
|
* - failures are classified: offline → stop; server 5xx / in-progress →
|
||||||
|
* retry next pass; client 4xx → count an attempt and poison after MAX.
|
||||||
|
*/
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
apiDelete,
|
||||||
|
apiPatch,
|
||||||
|
apiPost,
|
||||||
|
apiPut,
|
||||||
|
ApiClientError,
|
||||||
|
type WriteOptions,
|
||||||
|
} from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
getIdMap,
|
||||||
|
getOutboxOps,
|
||||||
|
removeOutboxOp,
|
||||||
|
setIdMapEntry,
|
||||||
|
updateOutboxOp,
|
||||||
|
type OutboxOp,
|
||||||
|
} from "@/lib/offline/offline-db";
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
/** Matches local placeholder ids like `local_1717…_a1b2c3`. */
|
||||||
|
const LOCAL_ID_RE = /local_[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*/g;
|
||||||
|
|
||||||
|
function getByPath(obj: unknown, path: string): string | undefined {
|
||||||
|
let cur: unknown = obj;
|
||||||
|
for (const part of path.split(".")) {
|
||||||
|
if (cur == null || typeof cur !== "object") return undefined;
|
||||||
|
cur = (cur as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return typeof cur === "string" ? cur : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace known local ids in the op's url/body with their server ids. Returns
|
||||||
|
* `blocked: true` if it still references an unresolved local id (its creator
|
||||||
|
* hasn't synced yet) other than the id this op itself creates.
|
||||||
|
*/
|
||||||
|
export function remapReferences(
|
||||||
|
op: Pick<OutboxOp, "url" | "body" | "createsClientId">,
|
||||||
|
idMap: Record<string, string>
|
||||||
|
): { url: string; body: unknown; blocked: boolean } {
|
||||||
|
let url = op.url;
|
||||||
|
let bodyStr = op.body !== undefined ? JSON.stringify(op.body) : "";
|
||||||
|
|
||||||
|
for (const [clientId, serverId] of Object.entries(idMap)) {
|
||||||
|
if (url.includes(clientId)) url = url.split(clientId).join(serverId);
|
||||||
|
if (bodyStr && bodyStr.includes(clientId)) bodyStr = bodyStr.split(clientId).join(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = `${url} ${bodyStr}`.match(LOCAL_ID_RE) ?? [];
|
||||||
|
const unresolved = remaining.filter((id) => id !== op.createsClientId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
body: bodyStr !== "" ? JSON.parse(bodyStr) : op.body,
|
||||||
|
blocked: unresolved.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendOp(op: OutboxOp, url: string, body: unknown): Promise<unknown> {
|
||||||
|
const opts: WriteOptions = { idempotencyKey: op.idempotencyKey };
|
||||||
|
switch (op.method) {
|
||||||
|
case "POST":
|
||||||
|
return apiPost(url, body, opts);
|
||||||
|
case "PUT":
|
||||||
|
return apiPut(url, body, opts);
|
||||||
|
case "PATCH":
|
||||||
|
return apiPatch(url, body, opts);
|
||||||
|
case "DELETE":
|
||||||
|
await apiDelete(url, opts);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Disposition = "offline" | "transient" | "permanent";
|
||||||
|
|
||||||
|
function classify(err: unknown): Disposition {
|
||||||
|
if (err instanceof ApiClientError) {
|
||||||
|
if (err.code === "IDEMPOTENCY_IN_PROGRESS") return "transient"; // another tab/pass owns it
|
||||||
|
if (err.status !== undefined && err.status >= 500) return "transient";
|
||||||
|
return "permanent"; // validation / 4xx — retrying the same payload won't help
|
||||||
|
}
|
||||||
|
if (isAxiosError(err)) {
|
||||||
|
if (!err.response) return "offline"; // network down
|
||||||
|
if (err.response.status >= 500) return "transient";
|
||||||
|
return "permanent";
|
||||||
|
}
|
||||||
|
return "permanent";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errMessage(err: unknown): string {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DrainResult = { sent: number; remaining: number; ran: boolean };
|
||||||
|
|
||||||
|
let draining = false;
|
||||||
|
|
||||||
|
/** Drain the outbox once, in causal order. Never throws. */
|
||||||
|
export async function drainOutbox(): Promise<DrainResult> {
|
||||||
|
const isOffline = typeof navigator !== "undefined" && !navigator.onLine;
|
||||||
|
if (draining || isOffline) {
|
||||||
|
return { sent: 0, remaining: (await getOutboxOps()).length, ran: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
draining = true;
|
||||||
|
let sent = 0;
|
||||||
|
try {
|
||||||
|
const idMap = await getIdMap();
|
||||||
|
const ops = await getOutboxOps();
|
||||||
|
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.status === "failed" && op.attempts >= MAX_ATTEMPTS) continue; // poisoned
|
||||||
|
|
||||||
|
const { url, body, blocked } = remapReferences(op, idMap);
|
||||||
|
if (blocked) continue; // a dependency hasn't synced yet; revisit next pass
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendOp(op, url, body);
|
||||||
|
if (op.createsClientId) {
|
||||||
|
const serverId = getByPath(result, op.idField ?? "id");
|
||||||
|
if (serverId) {
|
||||||
|
idMap[op.createsClientId] = serverId;
|
||||||
|
await setIdMapEntry(op.createsClientId, serverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await removeOutboxOp(op.id);
|
||||||
|
sent++;
|
||||||
|
} catch (err) {
|
||||||
|
const disposition = classify(err);
|
||||||
|
if (disposition === "offline") break; // stop the whole pass; resume when online
|
||||||
|
if (disposition === "transient") {
|
||||||
|
await updateOutboxOp(op.id, { lastError: errMessage(err) }); // retry, don't burn an attempt
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await updateOutboxOp(op.id, {
|
||||||
|
status: "failed",
|
||||||
|
attempts: op.attempts + 1,
|
||||||
|
lastError: errMessage(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
draining = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent, remaining: (await getOutboxOps()).length, ran: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ops that exhausted their retries and need user attention. */
|
||||||
|
export async function getPoisonedOps(): Promise<OutboxOp[]> {
|
||||||
|
const ops = await getOutboxOps();
|
||||||
|
return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Persists the React Query cache to IndexedDB so the dashboard is *viewable*
|
||||||
|
* offline (last-synced data) and survives a reload with no connection.
|
||||||
|
*
|
||||||
|
* Uses `dehydrate`/`hydrate` from @tanstack/react-query directly — no extra
|
||||||
|
* dependency. Writes are debounced; reads are guarded by a schema buster, a
|
||||||
|
* max-age, and a tenant scope so one café never hydrates another's data.
|
||||||
|
*/
|
||||||
|
import { dehydrate, hydrate, type QueryClient } from "@tanstack/react-query";
|
||||||
|
import { kvGet, kvSet } from "@/lib/offline/offline-db";
|
||||||
|
|
||||||
|
const CACHE_KEY = "rq-cache";
|
||||||
|
/** Bump when cached shapes change so stale persisted data is dropped on deploy. */
|
||||||
|
const BUSTER = "v1";
|
||||||
|
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
||||||
|
const SAVE_DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
|
type PersistedCache = {
|
||||||
|
buster: string;
|
||||||
|
timestamp: number;
|
||||||
|
/** Tenant/user scope this cache belongs to (cafeId, or "anon"). */
|
||||||
|
scope: string;
|
||||||
|
state: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate the query cache from IndexedDB if a valid snapshot exists for this
|
||||||
|
* scope. Safe to call before or after queries mount.
|
||||||
|
*/
|
||||||
|
export async function restoreQueryCache(qc: QueryClient, scope: string): Promise<void> {
|
||||||
|
const saved = await kvGet<PersistedCache>(CACHE_KEY);
|
||||||
|
if (!saved) return;
|
||||||
|
if (saved.buster !== BUSTER) return; // schema changed
|
||||||
|
if (saved.scope !== scope) return; // different tenant/user — do not leak
|
||||||
|
if (Date.now() - saved.timestamp > MAX_AGE_MS) return; // too old
|
||||||
|
try {
|
||||||
|
hydrate(qc, saved.state as never);
|
||||||
|
} catch {
|
||||||
|
// corrupt snapshot — ignore, it will be overwritten on next save
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to cache changes and persist a debounced snapshot. Returns an
|
||||||
|
* unsubscribe function.
|
||||||
|
*/
|
||||||
|
export function startPersisting(qc: QueryClient, scope: string): () => void {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
timer = null;
|
||||||
|
const snapshot: PersistedCache = {
|
||||||
|
buster: BUSTER,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
scope,
|
||||||
|
state: dehydrate(qc),
|
||||||
|
};
|
||||||
|
void kvSet(CACHE_KEY, snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = qc.getQueryCache().subscribe(() => {
|
||||||
|
if (timer) return; // a save is already scheduled
|
||||||
|
timer = setTimeout(save, SAVE_DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,87 +1,117 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||||||
import {
|
import {
|
||||||
|
enqueueOutboxOp,
|
||||||
getAllQueueItems,
|
getAllQueueItems,
|
||||||
|
getOutboxCount,
|
||||||
getQueueCount,
|
getQueueCount,
|
||||||
removeQueueItem,
|
removeQueueItem,
|
||||||
markQueueItemFailed,
|
|
||||||
} from "@/lib/offline/offline-db";
|
} from "@/lib/offline/offline-db";
|
||||||
import { apiPost } from "@/lib/api/client";
|
import { drainOutbox } from "@/lib/offline/outbox";
|
||||||
|
|
||||||
|
function newId(prefix: string): string {
|
||||||
|
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes one queued item and returns whether it succeeded.
|
* One-time migration of any items left in the legacy POS `order_queue` into the
|
||||||
|
* generic outbox, so orders queued before this release still sync. Best-effort.
|
||||||
*/
|
*/
|
||||||
async function processItem(item: Awaited<ReturnType<typeof getAllQueueItems>>[number]): Promise<boolean> {
|
async function migrateLegacyQueue(): Promise<void> {
|
||||||
|
let legacy: Awaited<ReturnType<typeof getAllQueueItems>> = [];
|
||||||
|
try {
|
||||||
|
legacy = await getAllQueueItems();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of legacy) {
|
||||||
try {
|
try {
|
||||||
if (item.type === "create_order") {
|
if (item.type === "create_order") {
|
||||||
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
|
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
|
||||||
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
|
await enqueueOutboxOp({
|
||||||
|
id: newId("op"),
|
||||||
|
idempotencyKey: newId("idem"),
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/cafes/${cafeId}/orders`,
|
||||||
|
body,
|
||||||
|
entityType: "order",
|
||||||
|
idField: "id",
|
||||||
|
createdAt: Date.parse(item.createdAt) || Date.now(),
|
||||||
|
});
|
||||||
} else if (item.type === "add_items") {
|
} else if (item.type === "add_items") {
|
||||||
const { cafeId, orderId, body } = item.payload as {
|
const { cafeId, orderId, body } = item.payload as {
|
||||||
cafeId: string;
|
cafeId: string;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
body: unknown;
|
body: unknown;
|
||||||
};
|
};
|
||||||
await apiPost(
|
await enqueueOutboxOp({
|
||||||
`/api/cafes/${cafeId}/orders/${orderId}/items`,
|
id: newId("op"),
|
||||||
body as Record<string, unknown>
|
idempotencyKey: newId("idem"),
|
||||||
);
|
method: "POST",
|
||||||
|
url: `/api/cafes/${cafeId}/orders/${orderId}/items`,
|
||||||
|
body,
|
||||||
|
entityType: "order_items",
|
||||||
|
createdAt: Date.parse(item.createdAt) || Date.now(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return true;
|
await removeQueueItem(item.id);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
// leave the legacy item in place; we'll try again next mount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call this hook once in the app shell to:
|
* Mount once in the app shell to:
|
||||||
* - Load initial queue count from IndexedDB on mount
|
* - migrate any legacy queued orders into the outbox,
|
||||||
* - Listen to online/offline events
|
* - keep the pending-count badge and online flag in sync,
|
||||||
* - Auto-sync when back online or tab becomes visible
|
* - drain the outbox when back online or the tab regains focus,
|
||||||
|
* - refresh server data once writes have synced.
|
||||||
*/
|
*/
|
||||||
export function useOfflineSync() {
|
export function useOfflineSync() {
|
||||||
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
|
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const syncLock = useRef(false);
|
const syncLock = useRef(false);
|
||||||
|
|
||||||
const refreshCount = useCallback(async () => {
|
const refreshCount = useCallback(async () => {
|
||||||
const n = await getQueueCount();
|
const n = (await getOutboxCount()) + (await getQueueCount());
|
||||||
setQueueCount(n);
|
setQueueCount(n);
|
||||||
return n;
|
return n;
|
||||||
}, [setQueueCount]);
|
}, [setQueueCount]);
|
||||||
|
|
||||||
const syncQueue = useCallback(async () => {
|
const syncQueue = useCallback(async () => {
|
||||||
if (syncLock.current) return;
|
if (syncLock.current) return;
|
||||||
if (!navigator.onLine) return;
|
if (typeof navigator !== "undefined" && !navigator.onLine) return;
|
||||||
const count = await refreshCount();
|
|
||||||
if (count === 0) return;
|
|
||||||
|
|
||||||
syncLock.current = true;
|
syncLock.current = true;
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
try {
|
try {
|
||||||
const items = await getAllQueueItems();
|
const result = await drainOutbox();
|
||||||
for (const item of items) {
|
if (result.sent > 0) {
|
||||||
if (item.status === "failed" && item.retries >= 3) continue; // give up after 3
|
// Replace optimistic local data with the authoritative server state.
|
||||||
const ok = await processItem(item);
|
await queryClient.invalidateQueries();
|
||||||
if (ok) {
|
|
||||||
await removeQueueItem(item.id);
|
|
||||||
} else {
|
|
||||||
await markQueueItemFailed(item.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
syncLock.current = false;
|
syncLock.current = false;
|
||||||
setSyncing(false);
|
setSyncing(false);
|
||||||
await refreshCount();
|
await refreshCount();
|
||||||
}
|
}
|
||||||
}, [refreshCount, setSyncing]);
|
}, [refreshCount, setSyncing, queryClient]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load initial count
|
void (async () => {
|
||||||
void refreshCount();
|
await migrateLegacyQueue();
|
||||||
|
await refreshCount();
|
||||||
|
// Drain anything pending if we mounted already online.
|
||||||
|
if (typeof navigator === "undefined" || navigator.onLine) void syncQueue();
|
||||||
|
})();
|
||||||
|
|
||||||
// Track online state
|
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
setOnline(true);
|
setOnline(true);
|
||||||
void syncQueue();
|
void syncQueue();
|
||||||
@@ -92,7 +122,6 @@ export function useOfflineSync() {
|
|||||||
window.addEventListener("online", handleOnline);
|
window.addEventListener("online", handleOnline);
|
||||||
window.addEventListener("offline", handleOffline);
|
window.addEventListener("offline", handleOffline);
|
||||||
|
|
||||||
// Sync when tab regains focus
|
|
||||||
const handleVisibility = () => {
|
const handleVisibility = () => {
|
||||||
if (document.visibilityState === "visible" && navigator.onLine) {
|
if (document.visibilityState === "visible" && navigator.onLine) {
|
||||||
void syncQueue();
|
void syncQueue();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { apiPost } from "@/lib/api/client";
|
|||||||
import type { Order, OrderItemLine } from "@/lib/api/types";
|
import type { Order, OrderItemLine } from "@/lib/api/types";
|
||||||
import type { CartItem } from "@/lib/stores/cart.store";
|
import type { CartItem } from "@/lib/stores/cart.store";
|
||||||
import { iranMobileForApi } from "@/lib/phone";
|
import { iranMobileForApi } from "@/lib/phone";
|
||||||
import { enqueueOfflineItem, getQueueCount } from "@/lib/offline/offline-db";
|
import { enqueueOutboxOp, getOutboxCount, getQueueCount } from "@/lib/offline/offline-db";
|
||||||
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||||||
|
|
||||||
export type SubmitOrderCart = {
|
export type SubmitOrderCart = {
|
||||||
@@ -24,7 +24,7 @@ export type SubmitOrderParams = {
|
|||||||
cartItems?: CartItem[];
|
cartItems?: CartItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Offline helpers ──────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function isNetworkError(err: unknown): boolean {
|
function isNetworkError(err: unknown): boolean {
|
||||||
if (err instanceof TypeError) {
|
if (err instanceof TypeError) {
|
||||||
@@ -36,6 +36,9 @@ function isNetworkError(err: unknown): boolean {
|
|||||||
msg.includes("network request failed")
|
msg.includes("network request failed")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// axios network errors surface as an Error with code ERR_NETWORK and no response.
|
||||||
|
const ax = err as { isAxiosError?: boolean; response?: unknown };
|
||||||
|
if (ax?.isAxiosError && !ax.response) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +46,36 @@ function newLocalId(): string {
|
|||||||
return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a synthetic Order that keeps the POS cart functional while offline */
|
/** A stable idempotency key used for BOTH the online attempt and any queued
|
||||||
function buildLocalOrder(
|
* replay of the same submit, so the server de-duplicates them. */
|
||||||
|
function newIdempotencyKey(): string {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||||||
|
return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for a create-order POST. */
|
||||||
|
function buildCreateBody(
|
||||||
params: SubmitOrderParams,
|
params: SubmitOrderParams,
|
||||||
cartItems: CartItem[]
|
pending: ReturnType<SubmitOrderCart["getPendingLines"]>
|
||||||
): Order {
|
) {
|
||||||
|
const { cart, orderBranchId, reservationId } = params;
|
||||||
|
return {
|
||||||
|
orderType: "DineIn",
|
||||||
|
branchId: orderBranchId,
|
||||||
|
tableId: cart.tableId ?? undefined,
|
||||||
|
reservationId: reservationId ?? undefined,
|
||||||
|
guestName: cart.guestName.trim() || undefined,
|
||||||
|
guestPhone: iranMobileForApi(cart.guestPhone),
|
||||||
|
customerId: cart.customerId ?? undefined,
|
||||||
|
couponId: cart.appliedCoupon?.id,
|
||||||
|
items: pending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a synthetic Order so the POS stays usable offline. Uses the supplied
|
||||||
|
* id so it matches the outbox op's createsClientId (enabling later remap). */
|
||||||
|
function buildLocalOrder(params: SubmitOrderParams, cartItems: CartItem[], orderId: string): Order {
|
||||||
const pending = params.cart.getPendingLines();
|
const pending = params.cart.getPendingLines();
|
||||||
const localId = newLocalId();
|
|
||||||
|
|
||||||
const items: OrderItemLine[] = pending.map((p) => {
|
const items: OrderItemLine[] = pending.map((p) => {
|
||||||
const ci = cartItems.find((c) => c.menuItem.id === p.menuItemId);
|
const ci = cartItems.find((c) => c.menuItem.id === p.menuItemId);
|
||||||
@@ -69,7 +95,7 @@ function buildLocalOrder(
|
|||||||
const total = subtotal + taxTotal;
|
const total = subtotal + taxTotal;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: localId,
|
id: orderId,
|
||||||
cafeId: params.cafeId,
|
cafeId: params.cafeId,
|
||||||
branchId: params.orderBranchId,
|
branchId: params.orderBranchId,
|
||||||
tableId: params.cart.tableId ?? undefined,
|
tableId: params.cart.tableId ?? undefined,
|
||||||
@@ -90,50 +116,58 @@ function buildLocalOrder(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshQueueBadge(): Promise<void> {
|
||||||
|
const count = (await getOutboxCount()) + (await getQueueCount());
|
||||||
|
useSyncQueueStore.getState().setQueueCount(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue the write and return a local mock order. Two cases:
|
||||||
|
* - create: enqueue POST /orders with a fresh local id as createsClientId;
|
||||||
|
* - add items: enqueue POST /orders/{id}/items. {id} may be a local id — the
|
||||||
|
* outbox blocks then remaps it once the create syncs.
|
||||||
|
*/
|
||||||
async function queueAndBuildLocalOrder(
|
async function queueAndBuildLocalOrder(
|
||||||
params: SubmitOrderParams,
|
params: SubmitOrderParams,
|
||||||
cartItems: CartItem[]
|
cartItems: CartItem[],
|
||||||
|
idempotencyKey: string
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
const pending = params.cart.getPendingLines();
|
const { cafeId, cart } = params;
|
||||||
|
const pending = cart.getPendingLines();
|
||||||
if (pending.length === 0) throw new Error("nothing pending");
|
if (pending.length === 0) throw new Error("nothing pending");
|
||||||
|
|
||||||
const isAddToExisting =
|
const activeId = cart.activeOrderId;
|
||||||
!!params.cart.activeOrderId &&
|
|
||||||
!params.cart.activeOrderId.startsWith("local_");
|
|
||||||
|
|
||||||
await enqueueOfflineItem({
|
if (activeId) {
|
||||||
|
// Add items to an existing order (real server id, or a not-yet-synced local id).
|
||||||
|
await enqueueOutboxOp({
|
||||||
id: newLocalId(),
|
id: newLocalId(),
|
||||||
type: isAddToExisting ? "add_items" : "create_order",
|
idempotencyKey,
|
||||||
cafeId: params.cafeId,
|
method: "POST",
|
||||||
targetOrderId: isAddToExisting ? params.cart.activeOrderId : null,
|
url: `/api/cafes/${cafeId}/orders/${activeId}/items`,
|
||||||
payload: isAddToExisting
|
|
||||||
? {
|
|
||||||
cafeId: params.cafeId,
|
|
||||||
orderId: params.cart.activeOrderId!,
|
|
||||||
body: { items: pending },
|
body: { items: pending },
|
||||||
}
|
entityType: "order_items",
|
||||||
: {
|
createdAt: Date.now(),
|
||||||
cafeId: params.cafeId,
|
|
||||||
body: {
|
|
||||||
orderType: "DineIn",
|
|
||||||
branchId: params.orderBranchId,
|
|
||||||
tableId: params.cart.tableId ?? undefined,
|
|
||||||
reservationId: params.reservationId ?? undefined,
|
|
||||||
guestName: params.cart.guestName.trim() || undefined,
|
|
||||||
guestPhone: iranMobileForApi(params.cart.guestPhone),
|
|
||||||
customerId: params.cart.customerId ?? undefined,
|
|
||||||
couponId: params.cart.appliedCoupon?.id,
|
|
||||||
items: pending,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
});
|
});
|
||||||
|
await refreshQueueBadge();
|
||||||
|
return buildLocalOrder(params, cartItems, activeId);
|
||||||
|
}
|
||||||
|
|
||||||
// Update global queue count
|
// Create a brand-new order. createsClientId lets later add-items ops remap.
|
||||||
const count = await getQueueCount();
|
const localOrderId = newLocalId();
|
||||||
useSyncQueueStore.getState().setQueueCount(count);
|
await enqueueOutboxOp({
|
||||||
|
id: newLocalId(),
|
||||||
return buildLocalOrder(params, cartItems);
|
idempotencyKey,
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/cafes/${cafeId}/orders`,
|
||||||
|
body: buildCreateBody(params, pending),
|
||||||
|
entityType: "order",
|
||||||
|
createsClientId: localOrderId,
|
||||||
|
idField: "id",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await refreshQueueBadge();
|
||||||
|
return buildLocalOrder(params, cartItems, localOrderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main export ──────────────────────────────────────────────────────────────
|
// ─── Main export ──────────────────────────────────────────────────────────────
|
||||||
@@ -145,47 +179,45 @@ export async function submitOrderToApi({
|
|||||||
reservationId,
|
reservationId,
|
||||||
cartItems = [],
|
cartItems = [],
|
||||||
}: SubmitOrderParams): Promise<Order> {
|
}: SubmitOrderParams): Promise<Order> {
|
||||||
|
const params: SubmitOrderParams = { cafeId, orderBranchId, cart, reservationId, cartItems };
|
||||||
const pending = cart.getPendingLines();
|
const pending = cart.getPendingLines();
|
||||||
if (pending.length === 0) throw new Error("nothing pending");
|
if (pending.length === 0) throw new Error("nothing pending");
|
||||||
|
|
||||||
const tryOnline = async (): Promise<Order> => {
|
const idempotencyKey = newIdempotencyKey();
|
||||||
if (cart.activeOrderId && !cart.activeOrderId.startsWith("local_")) {
|
const addingToLocalOrder = isLocalOrder(cart.activeOrderId);
|
||||||
return apiPost<Order>(`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`, {
|
|
||||||
items: pending,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return apiPost<Order>(`/api/cafes/${cafeId}/orders`, {
|
|
||||||
orderType: "DineIn",
|
|
||||||
branchId: orderBranchId,
|
|
||||||
tableId: cart.tableId ?? undefined,
|
|
||||||
reservationId: reservationId ?? undefined,
|
|
||||||
guestName: cart.guestName.trim() || undefined,
|
|
||||||
guestPhone: iranMobileForApi(cart.guestPhone),
|
|
||||||
customerId: cart.customerId ?? undefined,
|
|
||||||
couponId: cart.appliedCoupon?.id,
|
|
||||||
items: pending,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try online first
|
// Fast path: online, and either a new order or adding to a real server order.
|
||||||
if (navigator.onLine) {
|
// (Adding to a still-local order must be queued so the outbox can remap its id.)
|
||||||
|
if (typeof navigator !== "undefined" && navigator.onLine && !addingToLocalOrder) {
|
||||||
try {
|
try {
|
||||||
return await tryOnline();
|
if (cart.activeOrderId) {
|
||||||
|
return await apiPost<Order>(
|
||||||
|
`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`,
|
||||||
|
{ items: pending },
|
||||||
|
{ idempotencyKey }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await apiPost<Order>(
|
||||||
|
`/api/cafes/${cafeId}/orders`,
|
||||||
|
buildCreateBody(params, pending),
|
||||||
|
{ idempotencyKey }
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If it's a network error despite onLine flag, fall through to offline path
|
// Only fall back to the offline queue on a genuine network failure; a real
|
||||||
|
// server/validation error must surface. The same idempotencyKey is reused
|
||||||
|
// so the server de-dups if the failed attempt actually reached it.
|
||||||
if (!isNetworkError(err)) throw err;
|
if (!isNetworkError(err)) throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offline path: queue and return a local mock order
|
return queueAndBuildLocalOrder(params, cartItems, idempotencyKey);
|
||||||
return queueAndBuildLocalOrder({ cafeId, orderBranchId, cart, reservationId, cartItems }, cartItems);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function orderAmountDue(order: Order): number {
|
export function orderAmountDue(order: Order): number {
|
||||||
return Math.max(0, order.total - (order.paidAmount ?? 0));
|
return Math.max(0, order.total - (order.paidAmount ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the order was created locally (offline) and not yet synced */
|
/** True when the order was created locally (offline) and not yet synced. */
|
||||||
export function isLocalOrder(orderId: string | null): boolean {
|
export function isLocalOrder(orderId: string | null): boolean {
|
||||||
return !!orderId?.startsWith("local_");
|
return !!orderId?.startsWith("local_");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user