feat(api/offline): idempotency-key middleware for safe write retries

Backend half of offline Phase 1. Lets the offline outbox replay a write after a
lost response without executing it twice (e.g. an order whose POST reached the
server but whose reply never came back).

- IdempotencyRecord entity + table (unique index on (Scope, Key)); migration
  AddIdempotencyRecords. Standalone POCO — no tenant/soft-delete filters.
- IdempotencyMiddleware (after TenantMiddleware, before plan-limit/controllers):
  opt-in via `Idempotency-Key` header on POST/PUT/PATCH/DELETE.
    * Completed key → replays stored status+body with `Idempotent-Replay: true`.
    * In-progress key → 409 IDEMPOTENCY_IN_PROGRESS; the unique index serializes
      racing first requests; stale (>60s) reservations are recovered after a crash.
    * Only <500 responses are cached; 5xx is released so the client can retry.
  Bookkeeping runs in isolated DI scopes so it never contaminates the controller's
  unit of work. Keys are scoped per café — no cross-tenant collisions.
- 5 middleware tests (replay/execute-once, distinct key, pass-through, tenant
  isolation, 5xx-not-cached). Full suite 86 passing.

Next in Phase 1: generalize the POS order queue into a generic client outbox that
sends these keys and remaps client→server ids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 18:03:57 +03:30
parent 132f0921e0
commit f4583f5169
9 changed files with 3868 additions and 0 deletions
@@ -1129,6 +1129,54 @@ namespace Meezi.Infrastructure.Data.Migrations
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 =>
{
b.Property<string>("Id")