From 13665e0c3c62b8fee43b099c24fb8aeb272c9395 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 13 Sep 2026 18:05:03 +0200 Subject: [PATCH] feat(catalog): persist idempotent bulk operations and retryable deliveries --- docs/testing/database-integration.md | 4 +- drizzle/migrations/0031_operations_outbox.sql | 25 ++++ integration/database.test.ts | 115 ++++++++++++++++- scripts/jobs-worker.ts | 5 + src/actions/catalog-bulk.ts | 88 ++++++++----- src/app/admin/devops/deliveries/page.tsx | 47 +++++++ src/app/admin/devops/page.tsx | 4 + .../catalog/components/bulk-offer-editor.tsx | 11 +- .../catalog/server/bulk-actions.test.ts | 19 +-- .../catalog/server/bulk-offers.test.ts | 11 ++ src/features/catalog/server/bulk-offers.ts | 86 +++++++++---- src/features/catalog/server/bulk-undo.test.ts | 11 ++ .../foundation-source-contract.test.ts | 6 +- .../migration/discover-legacy-pages.test.ts | 2 +- .../housekeeping/migration/matrix.test.ts | 4 +- .../housekeeping/migration/system.test.ts | 4 +- src/features/housekeeping/migration/system.ts | 18 +++ src/features/operations/README.md | 9 ++ src/features/operations/dispatcher.test.ts | 51 ++++++++ src/features/operations/dispatcher.ts | 23 ++++ src/features/operations/model.test.ts | 31 +++++ src/features/operations/model.ts | 62 ++++++++++ src/features/operations/retry-action.test.ts | 51 ++++++++ src/features/operations/retry-action.ts | 35 ++++++ src/features/operations/retry-button.tsx | 23 ++++ src/features/operations/retry-effect.test.ts | 23 ++++ src/features/operations/server.ts | 117 ++++++++++++++++++ src/features/operations/worker.ts | 32 +++++ src/messages/ar.json | 19 +++ src/messages/bg.json | 19 +++ src/messages/cs.json | 19 +++ src/messages/da.json | 19 +++ src/messages/de.json | 19 +++ src/messages/el.json | 19 +++ src/messages/en.json | 19 +++ src/messages/es.json | 19 +++ src/messages/fi.json | 19 +++ src/messages/fr.json | 19 +++ src/messages/hr.json | 19 +++ src/messages/hu.json | 19 +++ src/messages/it.json | 19 +++ src/messages/ja.json | 19 +++ src/messages/nl.json | 19 +++ src/messages/no.json | 19 +++ src/messages/pl.json | 19 +++ src/messages/pt.json | 19 +++ src/messages/ro.json | 19 +++ src/messages/ru.json | 19 +++ src/messages/sk.json | 19 +++ src/messages/sr.json | 19 +++ src/messages/sv.json | 19 +++ src/messages/tr.json | 19 +++ src/messages/uk.json | 19 +++ 53 files changed, 1316 insertions(+), 76 deletions(-) create mode 100644 drizzle/migrations/0031_operations_outbox.sql create mode 100644 src/app/admin/devops/deliveries/page.tsx create mode 100644 src/features/operations/README.md create mode 100644 src/features/operations/dispatcher.test.ts create mode 100644 src/features/operations/dispatcher.ts create mode 100644 src/features/operations/model.test.ts create mode 100644 src/features/operations/model.ts create mode 100644 src/features/operations/retry-action.test.ts create mode 100644 src/features/operations/retry-action.ts create mode 100644 src/features/operations/retry-button.tsx create mode 100644 src/features/operations/retry-effect.test.ts create mode 100644 src/features/operations/server.ts create mode 100644 src/features/operations/worker.ts diff --git a/docs/testing/database-integration.md b/docs/testing/database-integration.md index e8f5fc25..da6daac5 100644 --- a/docs/testing/database-integration.md +++ b/docs/testing/database-integration.md @@ -2,8 +2,8 @@ Run `pnpm test:integration` on a Docker-capable host. Missing Docker or failed container setup fails the suite. CI runs this check before deployment. -Six tests exercise real MariaDB catalog transactions and Redis caching: migration CLI replay/status, committed bulk edits and undo history, complete rollback when the second audit insert fails, competing previews, cache expiry metadata and cross-key invalidation isolation. +Nine tests exercise real MariaDB catalog transactions and Redis caching: migration CLI replay/status, committed bulk edits and undo history, complete rollback when the second audit insert fails, competing previews, cache expiry metadata and cross-key invalidation isolation, concurrent request idempotency, operation/outbox rollback and exclusive delivery claims. Each execution starts disposable MariaDB11.4.5 and Redis7.4.2 containers with random exposed ports and generated passwords. No production URLs, volumes or credentials are used. The real migration CLI is copied beneath a temporary isolated fixture root so its environment loader cannot read the checkout environment file. Cleanup attempts all connections and containers even if a previous cleanup fails. -The emulator fixture contains minimal catalog and audit tables. Migrations0027-0029 are exercised; this is not certification of every historical emulator schema or migration. Operation/outbox tests are introduced with their implementation in the following increment. +The emulator fixture contains minimal catalog and audit tables. Migrations0027-0029 and0031 are exercised; this is not certification of every historical emulator schema or migration. diff --git a/drizzle/migrations/0031_operations_outbox.sql b/drizzle/migrations/0031_operations_outbox.sql new file mode 100644 index 00000000..5f8248d0 --- /dev/null +++ b/drizzle/migrations/0031_operations_outbox.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS cms_operations ( + id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY, + actor_id INT NOT NULL, + kind VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + request_key CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + request_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + result_json MEDIUMTEXT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY operation_request (actor_id,kind,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS cms_outbox ( + id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY, + operation_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + topic VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + available_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + lease_until DATETIME(3) NULL, + lease_token CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + last_error VARCHAR(255) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY operation_effect (operation_id,topic), + KEY delivery_pending (status,available_at), + KEY delivery_lease (status,lease_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/integration/database.test.ts b/integration/database.test.ts index 8437d0ca..68aeed1c 100644 --- a/integration/database.test.ts +++ b/integration/database.test.ts @@ -4,6 +4,7 @@ import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { sql } from "drizzle-orm"; import type { RowDataPacket } from "mysql2/promise"; import mysql from "mysql2/promise"; import { @@ -18,6 +19,7 @@ const migrations = [ "0027_catalog_packages.sql", "0028_history_snapshots.sql", "0029_admin_table_views.sql", + "0031_operations_outbox.sql", ]; let maria: StartedTestContainer | undefined; let redisContainer: StartedTestContainer | undefined; @@ -25,6 +27,7 @@ let connection: mysql.Connection | undefined; let appDb: typeof import("@/lib/db").db | undefined; let appRedis: typeof import("@/lib/redis").redis; let commands: typeof import("@/features/catalog/server/bulk-offers"); +let operations: typeof import("@/features/operations/server"); let cache: typeof import("@/lib/cache"); let migrationRoot: string | undefined; let databaseUrl: string; @@ -131,6 +134,7 @@ beforeAll(async () => { await appRedis.ping(); commands = await import("@/features/catalog/server/bulk-offers"); cache = await import("@/lib/cache"); + operations = await import("@/features/operations/server"); }); afterAll(async () => { @@ -159,6 +163,8 @@ afterAll(async () => { beforeEach(async () => { if (!connection) throw Error("Integration database is not connected"); + await connection.query("DELETE FROM cms_outbox"); + await connection.query("DELETE FROM cms_operations"); await connection.query("DROP TRIGGER IF EXISTS reject_second_history"); await connection.query("DELETE FROM admin_audit_log"); await connection.query("DELETE FROM catalog_items"); @@ -195,7 +201,7 @@ describe("MariaDB migrations and catalog transactions", () => { { name: "Saved" }, ]); expect((await migrate("--status")).stdout).toContain( - "3/3 applied, 0 pending", + "4/4 applied, 0 pending", ); const columns = await rows( "SELECT COLUMN_NAME, DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_audit_log' AND COLUMN_NAME IN ('before','after') ORDER BY COLUMN_NAME", @@ -306,3 +312,110 @@ describe("Redis application cache", () => { expect(await appRedis?.get(second)).toBe('"second"'); }); }); + +describe("operation idempotency and transactional outbox", () => { + it("replays concurrent identical requests after applying their mutation and effect only once", async () => { + const request = { + actorId: 7, + kind: "integration.test", + key: randomUUID(), + input: { increment: 3 }, + }; + let executions = 0; + const work = async ( + tx: import("@/features/operations/server").OperationTransaction, + id: string, + ) => { + executions++; + await tx.execute( + sql`UPDATE catalog_items SET cost_credits=cost_credits+3 WHERE id=1`, + ); + await operations.enqueueEffect(tx, id, "catalog.refresh"); + return { operationId: id, changed: 1 }; + }; + const result = await Promise.all([ + operations.runOperation(request, work), + operations.runOperation(request, work), + ]); + expect(result[0]).toEqual(result[1]); + expect(executions).toBe(1); + expect( + await rows("SELECT cost_credits FROM catalog_items WHERE id=1"), + ).toEqual([{ cost_credits: 13 }]); + expect(await rows("SELECT id FROM cms_operations")).toHaveLength(1); + expect(await rows("SELECT topic,status FROM cms_outbox")).toEqual([ + { topic: "catalog.refresh", status: "pending" }, + ]); + await expect( + operations.runOperation({ ...request, input: { increment: 9 } }, work), + ).rejects.toThrow(); + expect(executions).toBe(1); + expect( + await rows("SELECT cost_credits FROM catalog_items WHERE id=1"), + ).toEqual([{ cost_credits: 13 }]); + }); + + it("rolls back the request, mutation and queued effect together, allowing the same request to retry", async () => { + const request = { + actorId: 7, + kind: "integration.test", + key: randomUUID(), + input: { increment: 3 }, + }; + await expect( + operations.runOperation(request, async (tx, id) => { + await tx.execute( + sql`UPDATE catalog_items SET cost_credits=99 WHERE id=1`, + ); + await operations.enqueueEffect(tx, id, "catalog.refresh"); + throw Error("failure after effect enqueue"); + }), + ).rejects.toThrow("failure after effect enqueue"); + expect( + await rows("SELECT cost_credits FROM catalog_items WHERE id=1"), + ).toEqual([{ cost_credits: 10 }]); + expect(await rows("SELECT id FROM cms_operations")).toHaveLength(0); + expect(await rows("SELECT id FROM cms_outbox")).toHaveLength(0); + await expect( + operations.runOperation(request, async (tx, id) => { + await tx.execute( + sql`UPDATE catalog_items SET cost_credits=13 WHERE id=1`, + ); + await operations.enqueueEffect(tx, id, "catalog.refresh"); + return { changed: 1 }; + }), + ).resolves.toEqual({ changed: 1 }); + expect( + await rows("SELECT cost_credits FROM catalog_items WHERE id=1"), + ).toEqual([{ cost_credits: 13 }]); + expect(await rows("SELECT id FROM cms_outbox")).toHaveLength(1); + }); + + it("leases a queued effect to only one consumer and ignores a stale completion token", async () => { + await operations.runOperation( + { actorId: 7, kind: "integration.test", key: randomUUID(), input: {} }, + async (tx, id) => { + await operations.enqueueEffect(tx, id, "catalog.refresh"); + return { queued: true }; + }, + ); + const claims = await Promise.all([ + operations.effectRepository.claim(), + operations.effectRepository.claim(), + ]); + expect(claims.filter(Boolean)).toHaveLength(1); + const claim = claims.find((value) => value !== null); + if (!claim) throw Error("Expected one effect lease"); + await operations.effectRepository.complete({ + ...claim, + token: randomUUID(), + }); + expect(await rows("SELECT status FROM cms_outbox")).toEqual([ + { status: "running" }, + ]); + await operations.effectRepository.complete(claim); + expect(await rows("SELECT status FROM cms_outbox")).toEqual([ + { status: "done" }, + ]); + }); +}); diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index d82e4ff3..fe6df8b3 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -1,3 +1,4 @@ +import { drainOperationEffects } from "../src/features/operations/worker"; import { drainFurnitureImports } from "../src/lib/services/furni-job-worker"; import "./load-env"; import { Cron } from "croner"; @@ -381,6 +382,10 @@ async function reportWorkerHeartbeat(): Promise { } async function main() { + new Cron("* * * * *", () => { + void drainOperationEffects(); + }); + void drainOperationEffects(); new Cron("* * * * *", () => { void drainFurnitureImports(); }); diff --git a/src/actions/catalog-bulk.ts b/src/actions/catalog-bulk.ts index bec539ab..e0fc5cb7 100644 --- a/src/actions/catalog-bulk.ts +++ b/src/actions/catalog-bulk.ts @@ -8,51 +8,52 @@ import { undoBulkOffersCommand, } from "@/features/catalog/server/bulk-offers"; import { catalogFailure } from "@/features/catalog/server/errors"; -import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; +import { OperationConflict } from "@/features/operations/model"; import { requirePermission } from "@/lib/admin/guard"; import { logger } from "@/lib/logger"; import { PERMS } from "@/lib/permissions"; -import { logAudit } from "@/lib/services/audit"; import { withCatalogExport } from "@/lib/services/catalog-git-queue"; export async function previewBulkOffers(input: BulkOfferInput) { await requirePermission(PERMS.CATALOG_VIEW); try { return { ok: true as const, data: await previewBulkOffersCommand(input) }; } catch (error) { - return { ok: false as const, error: catalogFailure(error).message }; + return { + ok: false as const, + error: + error instanceof OperationConflict + ? error.message + : catalogFailure(error).message, + }; } } export async function applyBulkOffers( input: BulkOfferInput, fingerprint: string, + requestKey?: string, ) { const staff = await requirePermission(PERMS.CATALOG_EDIT); try { return await withCatalogExport(async () => { - const data = await applyBulkOffersCommand(input, fingerprint, staff.id); + const data = await applyBulkOffersCommand( + input, + fingerprint, + staff.id, + requestKey, + ); if (data.changedCount > 0) { - await logAudit({ - userId: staff.id, - action: "catalog_offers_bulk_update", - target: "catalog", - after: { - ids: input.ids, - changes: input.changes, - changedCount: data.changedCount, - }, - }).catch((error) => - logger.error("Catalog bulk update committed; audit write failed", { - module: "catalog", - error, - }), - ); - await sendCatalogUpdate(); - revalidatePath("/admin/catalog", "layout"); + refreshCatalog(); } return { ok: true as const, data }; }); } catch (error) { - return { ok: false as const, error: catalogFailure(error).message }; + return { + ok: false as const, + error: + error instanceof OperationConflict + ? error.message + : catalogFailure(error).message, + }; } } @@ -64,21 +65,50 @@ export async function getBulkOfferDestinations() { data: await listBulkOfferDestinationsCommand(), }; } catch (error) { - return { ok: false as const, error: catalogFailure(error).message }; + return { + ok: false as const, + error: + error instanceof OperationConflict + ? error.message + : catalogFailure(error).message, + }; } } -export async function undoBulkOffers(historyIds: number[]) { +export async function undoBulkOffers( + historyIds: number[], + requestKey?: string, +) { const staff = await requirePermission(PERMS.CATALOG_EDIT); try { return await withCatalogExport(async () => { - const data = await undoBulkOffersCommand(historyIds, staff.id); - await sendCatalogUpdate(); - revalidatePath("/admin/catalog", "layout"); - revalidatePath("/admin/logs/audit"); + const data = await undoBulkOffersCommand( + historyIds, + staff.id, + requestKey, + ); + refreshCatalog(); return { ok: true as const, data }; }); } catch (error) { - return { ok: false as const, error: catalogFailure(error).message }; + return { + ok: false as const, + error: + error instanceof OperationConflict + ? error.message + : catalogFailure(error).message, + }; + } +} + +function refreshCatalog() { + try { + revalidatePath("/admin/catalog", "layout"); + revalidatePath("/admin/logs/audit"); + } catch (error) { + logger.error("Catalog committed; page invalidation failed", { + module: "catalog", + error, + }); } } diff --git a/src/app/admin/devops/deliveries/page.tsx b/src/app/admin/devops/deliveries/page.tsx new file mode 100644 index 00000000..c1b79189 --- /dev/null +++ b/src/app/admin/devops/deliveries/page.tsx @@ -0,0 +1,47 @@ +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import Link from "@/components/link"; +import { DeliveryRetry } from "@/features/operations/retry-button"; +import { listEffects } from "@/features/operations/server"; +import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +export default async function DeliveryPage() { + const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) + redirect("/admin"); + const editable = canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank); + const t = await getTranslations("pages.admin.deliveries"); + const items = await listEffects(); + return ( +
+

{t("title")}

+

{t("scope")}

+ + {t("refresh")} + + {!items.length ? ( +

{t("empty")}

+ ) : ( +
    + {items.map((item) => ( +
  • +
    + + {t(item.topic === "catalog.refresh" ? "hotel" : "export")} + + {t(`states.${item.status}`)} +
    +

    + {item.operationId} · {t("actor", { id: item.actorId })} ·{" "} + {t("attempts", { count: item.attempts })} +

    + {item.status === "failed" &&

    {t("retryError")}

    } + {editable && item.status === "failed" && ( + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/admin/devops/page.tsx b/src/app/admin/devops/page.tsx index 911b0d55..c25ac694 100644 --- a/src/app/admin/devops/page.tsx +++ b/src/app/admin/devops/page.tsx @@ -54,6 +54,7 @@ export default async function DevOpsPage() { const t = await getTranslations("pages.admin.devops"); const data = await getDevOpsData(); const performance = await getTranslations("pages.admin.performance"); + const deliveries = await getTranslations("pages.admin.deliveries"); const installation = await getTranslations("pages.admin.installation"); return ( @@ -67,6 +68,9 @@ export default async function DevOpsPage() { {performance("title")} + + {deliveries("title")} + {/* Status cards */}
diff --git a/src/features/catalog/components/bulk-offer-editor.tsx b/src/features/catalog/components/bulk-offer-editor.tsx index 495c0adb..9fadb2aa 100644 --- a/src/features/catalog/components/bulk-offer-editor.tsx +++ b/src/features/catalog/components/bulk-offer-editor.tsx @@ -82,6 +82,7 @@ export function BulkOfferEditor({ const [preview, setPreview] = useState<{ input: BulkInput; data: Preview; + requestKey: string; } | null>(null); const [busy, setBusy] = useState(false); const busyRef = useRef(false); @@ -149,7 +150,7 @@ export function BulkOfferEditor({ setError(result.error); return; } - setPreview({ input, data: result.data }); + setPreview({ input, data: result.data, requestKey: crypto.randomUUID() }); } catch { setError(t("requestFailed")); } finally { @@ -172,12 +173,14 @@ export function BulkOfferEditor({ const result = await applyBulkOffers( preview.input, preview.data.fingerprint, + preview.requestKey, ); if (!result.ok) { setError(result.error); setPreview(null); return; } + const undoKey = crypto.randomUUID(); toast.success(t("success", { count: result.data.changedCount }), { duration: 15000, description: t("undoHint"), @@ -188,7 +191,10 @@ export function BulkOfferEditor({ if (busyRef.current || (beforeEdit && !beforeEdit())) return; busyRef.current = true; try { - const restored = await undoBulkOffers(result.data.historyIds); + const restored = await undoBulkOffers( + result.data.historyIds, + undoKey, + ); if (!restored.ok) { toast.error(restored.error); return; @@ -210,7 +216,6 @@ export function BulkOfferEditor({ await onApplied(); } catch { setError(t("requestFailed")); - setPreview(null); } finally { busyRef.current = false; setBusy(false); diff --git a/src/features/catalog/server/bulk-actions.test.ts b/src/features/catalog/server/bulk-actions.test.ts index 730d5898..3ef233be 100644 --- a/src/features/catalog/server/bulk-actions.test.ts +++ b/src/features/catalog/server/bulk-actions.test.ts @@ -57,10 +57,8 @@ it("gates edits before database and sends one update after commit", async () => expect((await applyBulkOffers(input, "x")).ok).toBe(true); expect(state.permission).toHaveBeenCalledWith("edit"); expect(state.export).toHaveBeenCalledTimes(1); - expect(state.send).toHaveBeenCalledTimes(1); - expect(state.apply.mock.invocationCallOrder[0]).toBeLessThan( - state.send.mock.invocationCallOrder[0], - ); + expect(state.send).not.toHaveBeenCalled(); + expect(state.refresh).toHaveBeenCalled(); }); it("stops denied permission before export or data access", async () => { state.permission.mockRejectedValueOnce(Error("Denied")); @@ -76,7 +74,7 @@ it("does not notify hotel when a transaction fails", async () => { it("does not report a committed edit as failed on audit failure", async () => { state.audit.mockRejectedValueOnce(Error("log failed")); expect((await applyBulkOffers(input, "x")).ok).toBe(true); - expect(state.send).toHaveBeenCalledTimes(1); + expect(state.send).not.toHaveBeenCalled(); }); it("loads destination choices with view permission only", async () => { @@ -95,9 +93,9 @@ it("undo enforces edit access and exports only successful restores", async () => state.undo.mockResolvedValueOnce({ changedCount: 2 }); expect((await undoBulkOffers([10, 11])).ok).toBe(true); expect(state.permission).toHaveBeenCalledWith("edit"); - expect(state.undo).toHaveBeenCalledWith([10, 11], 1); + expect(state.undo).toHaveBeenCalledWith([10, 11], 1, undefined); expect(state.export).toHaveBeenCalledTimes(1); - expect(state.send).toHaveBeenCalledTimes(1); + expect(state.send).not.toHaveBeenCalled(); }); it("undo denial stops before export and a conflict does not notify", async () => { state.permission.mockRejectedValueOnce(Error("Denied")); @@ -107,3 +105,10 @@ it("undo denial stops before export and a conflict does not notify", async () => expect((await undoBulkOffers([10])).ok).toBe(false); expect(state.send).not.toHaveBeenCalled(); }); + +it("passes a stable operation key and does not synchronously notify after commit", async () => { + const key = "123e4567-e89b-42d3-a456-426614174000"; + expect((await applyBulkOffers(input, "x", key)).ok).toBe(true); + expect(state.apply).toHaveBeenCalledWith(input, "x", 1, key); + expect(state.send).not.toHaveBeenCalled(); +}); diff --git a/src/features/catalog/server/bulk-offers.test.ts b/src/features/catalog/server/bulk-offers.test.ts index ca02264f..f044722c 100644 --- a/src/features/catalog/server/bulk-offers.test.ts +++ b/src/features/catalog/server/bulk-offers.test.ts @@ -115,6 +115,17 @@ vi.mock("@/lib/db", () => ({ }, })); +vi.mock("@/features/operations/server", async () => { + const { db } = await import("@/lib/db"); + return { + runOperation: ( + _input: unknown, + work: (tx: unknown, id: string) => Promise, + ) => db.transaction((tx) => work(tx, "operation-fixture")), + enqueueEffect: vi.fn(), + }; +}); + import { applyBulkOffersCommand, listBulkOfferDestinationsCommand, diff --git a/src/features/catalog/server/bulk-offers.ts b/src/features/catalog/server/bulk-offers.ts index aa565c2a..d60d9a4b 100644 --- a/src/features/catalog/server/bulk-offers.ts +++ b/src/features/catalog/server/bulk-offers.ts @@ -1,5 +1,5 @@ import "server-only"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { sql } from "drizzle-orm"; import { applyHistory, @@ -7,6 +7,7 @@ import { readHistory, recordHistory, } from "@/features/history/server"; +import { enqueueEffect, runOperation } from "@/features/operations/server"; import { db } from "@/lib/db"; import { type BulkOfferInput, @@ -115,11 +116,12 @@ export async function applyBulkOffersCommand( value: BulkOfferInput, fingerprint: string, userId?: number, + requestKey: string = randomUUID(), ) { const input = bulkOfferInputSchema.parse(value); if (!/^[a-f0-9]{64}$/.test(fingerprint)) throw new CatalogInputError("A valid preview is required"); - return db.transaction(async (tx) => { + const work = async (tx: Tx, operationId?: string) => { // Read references, then lock pages before offers to match category structural commands. const initial = await readOffers(tx, input.ids); const pages = await readPages(tx, initial, input, true); @@ -167,8 +169,27 @@ export async function applyBulkOffersCommand( historyIds.push(historyId); } } + if (operationId && result.changedCount > 0) { + await enqueueEffect(tx, operationId, "catalog.refresh"); + await enqueueEffect(tx, operationId, "catalog.export.request"); + } return { changedCount: result.changedCount, historyIds }; - }); + }; + return userId + ? runOperation( + { + actorId: userId, + kind: "catalog.bulk.apply", + key: requestKey, + input: { + ...input, + ids: [...input.ids].sort((a, b) => a - b), + fingerprint, + }, + }, + work, + ) + : db.transaction((tx) => work(tx)); } export async function listBulkOfferDestinationsCommand() { @@ -188,6 +209,7 @@ export async function listBulkOfferDestinationsCommand() { export async function undoBulkOffersCommand( historyIds: number[], userId: number, + requestKey: string = randomUUID(), ) { if ( !Array.isArray(historyIds) || @@ -197,27 +219,39 @@ export async function undoBulkOffersCommand( historyIds.some((id) => !Number.isSafeInteger(id) || id < 1) ) throw new CatalogInputError("Invalid undo selection"); - return db.transaction(async (tx) => { - const entries = []; - for (const id of historyIds) { - const entry = await readHistory(tx, id); - if (entry.kind !== "catalog_offer") - throw new CatalogInputError("Invalid undo selection"); - entries.push(entry); - } - entries.sort((a, b) => Number(a.targetId) - Number(b.targetId)); - if (new Set(entries.map((entry) => entry.targetId)).size !== entries.length) - throw new CatalogInputError("Duplicate undo target"); - await lockOfferHistoryPages(tx, entries); - try { - for (const entry of entries) await applyHistory(tx, entry, userId); - } catch (error) { - if (error instanceof Error && error.message === "conflict") - throw new CatalogConflict( - "Offers changed after this update. Undo was not applied.", - ); - throw error; - } - return { changedCount: entries.length }; - }); + return runOperation( + { + actorId: userId, + kind: "catalog.bulk.undo", + key: requestKey, + input: { historyIds: [...historyIds].sort((a, b) => a - b) }, + }, + async (tx, operationId) => { + const entries = []; + for (const id of historyIds) { + const entry = await readHistory(tx, id); + if (entry.kind !== "catalog_offer") + throw new CatalogInputError("Invalid undo selection"); + entries.push(entry); + } + entries.sort((a, b) => Number(a.targetId) - Number(b.targetId)); + if ( + new Set(entries.map((entry) => entry.targetId)).size !== entries.length + ) + throw new CatalogInputError("Duplicate undo target"); + await lockOfferHistoryPages(tx, entries); + try { + for (const entry of entries) await applyHistory(tx, entry, userId); + } catch (error) { + if (error instanceof Error && error.message === "conflict") + throw new CatalogConflict( + "Offers changed after this update. Undo was not applied.", + ); + throw error; + } + await enqueueEffect(tx, operationId, "catalog.refresh"); + await enqueueEffect(tx, operationId, "catalog.export.request"); + return { changedCount: entries.length }; + }, + ); } diff --git a/src/features/catalog/server/bulk-undo.test.ts b/src/features/catalog/server/bulk-undo.test.ts index a88d441a..f4e7dda1 100644 --- a/src/features/catalog/server/bulk-undo.test.ts +++ b/src/features/catalog/server/bulk-undo.test.ts @@ -79,6 +79,17 @@ vi.mock("@/lib/db", () => ({ }, })); +vi.mock("@/features/operations/server", async () => { + const { db } = await import("@/lib/db"); + return { + runOperation: ( + _input: unknown, + work: (tx: unknown, id: string) => Promise, + ) => db.transaction((tx) => work(tx, "operation-fixture")), + enqueueEffect: vi.fn(), + }; +}); + import { undoBulkOffersCommand } from "./bulk-offers"; beforeEach(() => { diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index d487e296..c53f2c16 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -605,15 +605,15 @@ describe("housekeeping foundation completion contracts", () => { expect(html).toContain(`>${sentinel}`); }); - it("validates the complete 142-row migration matrix without issues", () => { + it("validates the complete 143-row migration matrix without issues", () => { const discovered = discoverLegacyPages(); const issues = validateMigrationEntries( discovered, HOUSEKEEPING_MIGRATION_MATRIX, ); - expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(142); - expect(discovered).toHaveLength(142); + expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(143); + expect(discovered).toHaveLength(143); expect(issues).toEqual([]); }); }); diff --git a/src/features/housekeeping/migration/discover-legacy-pages.test.ts b/src/features/housekeeping/migration/discover-legacy-pages.test.ts index 1d642f0d..6f50e1e2 100644 --- a/src/features/housekeeping/migration/discover-legacy-pages.test.ts +++ b/src/features/housekeeping/migration/discover-legacy-pages.test.ts @@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => { it("discovers the exact legacy administration inventory", () => { const pages = discoverLegacyPages(); - expect(pages).toHaveLength(142); + expect(pages).toHaveLength(143); expect(pages).toContainEqual({ surface: "admin", legacyPath: "/admin/users/:id/edit", diff --git a/src/features/housekeeping/migration/matrix.test.ts b/src/features/housekeeping/migration/matrix.test.ts index 63b93e49..4b2514a6 100644 --- a/src/features/housekeeping/migration/matrix.test.ts +++ b/src/features/housekeeping/migration/matrix.test.ts @@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix"; import { validateMigrationEntries } from "./validate-matrix"; describe("HOUSEKEEPING_MIGRATION_MATRIX", () => { - it("covers all 142 legacy pages exactly once", () => { + it("covers all 143 legacy pages exactly once", () => { const discovered = discoverLegacyPages(); - expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(142); + expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(143); expect( validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX), ).toEqual([]); diff --git a/src/features/housekeeping/migration/system.test.ts b/src/features/housekeeping/migration/system.test.ts index 5f2155a0..d115866a 100644 --- a/src/features/housekeeping/migration/system.test.ts +++ b/src/features/housekeeping/migration/system.test.ts @@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [ ] as const; describe("systemMigrationEntries", () => { - it("covers all 22 System pages exactly once", () => { - expect(systemMigrationEntries).toHaveLength(22); + it("covers all 23 System pages exactly once", () => { + expect(systemMigrationEntries).toHaveLength(23); expect( validateMigrationEntries( ownedLegacyPages(SYSTEM_PREFIXES), diff --git a/src/features/housekeeping/migration/system.ts b/src/features/housekeeping/migration/system.ts index 26dae218..a46900b2 100644 --- a/src/features/housekeeping/migration/system.ts +++ b/src/features/housekeeping/migration/system.ts @@ -58,6 +58,24 @@ export const systemMigrationEntries: readonly MigrationEntry[] = [ localization: "PARTIAL", accessibility: "PARTIAL", }), + plannedSystemEntry({ + surface: "admin", + legacyPath: "/admin/devops/deliveries", + sourceFile: "src/app/admin/devops/deliveries/page.tsx", + targetPath: "/admin/system/operations/deliveries", + decision: "REHOST", + capabilities: { read: [PERMS.DEVOPS_VIEW], mutate: [PERMS.DEVOPS_EDIT] }, + dependencies: { + queries: ["cms_operations", "cms_outbox"], + mutations: ["retryDelivery"], + }, + auditRequirement: "PRIVILEGED_MUTATION", + localization: "PARTIAL", + accessibility: "PARTIAL", + notes: [ + "Retries failed effects only; does not replay committed catalog mutations", + ], + }), // Operator alerts and read-only analytics. plannedSystemEntry({ surface: "admin", diff --git a/src/features/operations/README.md b/src/features/operations/README.md new file mode 100644 index 00000000..ebcf4a4e --- /dev/null +++ b/src/features/operations/README.md @@ -0,0 +1,9 @@ +# Durable catalog operations + +Bulk offer apply and undo accept a request UUID. The authenticated actor, operation kind and UUID identify one mutation. A canonical payload hash rejects reuse for different changes. The result and outbox entries commit in the same database transaction as the offers and audit history; replay returns the stored result. + +The jobs worker claims pending effects under a database lock with a 120-second lease. A claim token protects completion from stale workers. Failed delivery uses exponential retry, stopping after eight attempts. `/admin/devops/deliveries` shows the latest 100 effects to DEVOPS_VIEW; DEVOPS_EDIT can retry failed effects without replaying the catalog mutation. + +Delivery is at least once: a crash after sending and before acknowledgement may repeat an effect. Catalog refresh is repeatable; export delivery means a request entered the existing export queue, not that Git publication or client refresh completed. Export disabled by configuration remains a no-op. This first adapter covers bulk offer apply/undo, not every CMS mutation. + +Migration0031 is required before the updated worker starts. Unit tests cover payload identity, dispatch limits, retries and authorization; the separate Docker integration suite covers concurrent SQL requests, transaction rollback and claim ownership. No production database was used for local tests. diff --git a/src/features/operations/dispatcher.test.ts b/src/features/operations/dispatcher.test.ts new file mode 100644 index 00000000..7e02737b --- /dev/null +++ b/src/features/operations/dispatcher.test.ts @@ -0,0 +1,51 @@ +import { expect, it, vi } from "vitest"; +import { dispatchEffects } from "./dispatcher"; + +it("settles only after successful delivery and preserves the claim token", async () => { + const effect = { + id: "a", + token: "lease1", + topic: "catalog.refresh" as const, + attempts: 1, + }; + const repo = { + claim: vi.fn().mockResolvedValueOnce(effect).mockResolvedValue(null), + complete: vi.fn(), + fail: vi.fn(), + }; + const deliver = vi.fn(); + await dispatchEffects(repo, deliver); + expect(deliver).toHaveBeenCalledWith(effect); + expect(repo.complete).toHaveBeenCalledWith(effect); + expect(repo.fail).not.toHaveBeenCalled(); +}); +it("persists failures and continues to other pending work", async () => { + const effect = { + id: "a", + token: "lease1", + topic: "catalog.refresh" as const, + attempts: 1, + }; + const repo = { + claim: vi.fn().mockResolvedValueOnce(effect).mockResolvedValue(null), + complete: vi.fn(), + fail: vi.fn(), + }; + await dispatchEffects(repo, vi.fn().mockRejectedValue(Error("private"))); + expect(repo.fail).toHaveBeenCalledWith(effect); + expect(repo.complete).not.toHaveBeenCalled(); +}); +it("bounds work per tick instead of draining indefinitely", async () => { + const repo = { + claim: vi.fn().mockResolvedValue({ + id: "a", + token: "t", + topic: "catalog.refresh", + attempts: 1, + }), + complete: vi.fn(), + fail: vi.fn(), + }; + await dispatchEffects(repo, async () => {}); + expect(repo.claim).toHaveBeenCalledTimes(20); +}); diff --git a/src/features/operations/dispatcher.ts b/src/features/operations/dispatcher.ts new file mode 100644 index 00000000..a6239c04 --- /dev/null +++ b/src/features/operations/dispatcher.ts @@ -0,0 +1,23 @@ +import type { EffectClaim } from "./model"; +export interface EffectRepository { + claim(): Promise; + complete(claim: EffectClaim): Promise; + fail(claim: EffectClaim): Promise; +} +/** At-least-once delivery: handlers must tolerate replay after an expired claim. */ +export async function dispatchEffects( + repo: EffectRepository, + deliver: (claim: EffectClaim) => Promise, +) { + for (let i = 0; i < 20; i++) { + const claim = await repo.claim(); + if (!claim) return; + try { + await deliver(claim); + } catch { + await repo.fail(claim); + continue; + } + await repo.complete(claim); + } +} diff --git a/src/features/operations/model.test.ts b/src/features/operations/model.test.ts new file mode 100644 index 00000000..cfe0b094 --- /dev/null +++ b/src/features/operations/model.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "vitest"; +import { operationHash, retryDelay, validateOperation } from "./model"; + +it("hashes equivalent object payloads equally without ignoring array order", () => { + expect(operationHash({ b: 2, a: { y: 1, x: 0 } })).toBe( + operationHash({ a: { x: 0, y: 1 }, b: 2 }), + ); + expect(operationHash([1, 2])).not.toBe(operationHash([2, 1])); +}); +it("rejects unsupported or oversized input rather than hashing ambiguous values", () => { + expect(() => operationHash({ amount: NaN })).toThrow(); + expect(() => operationHash({ value: undefined })).toThrow(); + expect(() => operationHash("x".repeat(100000))).toThrow(); +}); +it("validates actor and request identity before database access", () => { + const valid = { + actorId: 1, + kind: "catalog.bulk.apply", + key: "123e4567-e89b-42d3-a456-426614174000", + input: {}, + }; + expect(() => validateOperation(valid)).not.toThrow(); + expect(() => validateOperation({ ...valid, actorId: 0 })).toThrow(); + expect(() => validateOperation({ ...valid, key: "shared" })).toThrow(); + expect(() => validateOperation({ ...valid, kind: "bad kind" })).toThrow(); +}); +it("backs off boundedly for repeated delivery failures", () => { + expect(retryDelay(1)).toBe(60); + expect(retryDelay(2)).toBe(120); + expect(retryDelay(20)).toBe(3600); +}); diff --git a/src/features/operations/model.ts b/src/features/operations/model.ts new file mode 100644 index 00000000..55165689 --- /dev/null +++ b/src/features/operations/model.ts @@ -0,0 +1,62 @@ +import { createHash } from "node:crypto"; +export interface OperationInput { + actorId: number; + kind: string; + key: string; + input: unknown; +} +export class OperationConflict extends Error { + constructor() { + super( + "This request key was already used for different changes. Refresh the preview.", + ); + } +} +export function validateOperation(value: OperationInput) { + if ( + !Number.isSafeInteger(value.actorId) || + value.actorId < 1 || + !/^[a-z][a-z0-9.-]{2,63}$/.test(value.kind) || + !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test( + value.key, + ) + ) + throw new Error("Invalid operation identity"); +} +function canonical(value: unknown, depth = 0): unknown { + if (depth > 30) throw new Error("Operation input is too deep"); + if (value === null || typeof value === "string" || typeof value === "boolean") + return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (Array.isArray(value)) return value.map((v) => canonical(v, depth + 1)); + if ( + value && + typeof value === "object" && + Object.getPrototypeOf(value) === Object.prototype + ) + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [ + key, + canonical((value as Record)[key], depth + 1), + ]), + ); + throw new Error("Unsupported operation input"); +} +export function operationHash(value: unknown) { + const serialized = JSON.stringify(canonical(value)); + if (Buffer.byteLength(serialized) > 65536) + throw new Error("Operation input is too large"); + return createHash("sha256").update(serialized).digest("hex"); +} +export function retryDelay(attempt: number) { + return Math.min(3600, 60 * 2 ** Math.max(0, attempt - 1)); +} +export type EffectTopic = "catalog.refresh" | "catalog.export.request"; +export interface EffectClaim { + id: string; + token: string; + topic: EffectTopic; + attempts: number; +} diff --git a/src/features/operations/retry-action.test.ts b/src/features/operations/retry-action.test.ts new file mode 100644 index 00000000..092cde76 --- /dev/null +++ b/src/features/operations/retry-action.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + permission: vi.fn(), + retry: vi.fn(), + audit: vi.fn(), + refresh: vi.fn(), + error: vi.fn(), +})); +vi.mock("next/cache", () => ({ revalidatePath: mocks.refresh })); +vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.permission })); +vi.mock("@/lib/logger", () => ({ logger: { error: mocks.error } })); +vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit })); +vi.mock("./server", () => ({ retryEffect: mocks.retry })); + +import { retryDelivery } from "./retry-action"; + +const id = "840f4023-04cd-4d7b-92c5-047aa620029c"; +beforeEach(() => { + vi.resetAllMocks(); + mocks.permission.mockResolvedValue({ id: 7 }); + mocks.retry.mockResolvedValue(undefined); + mocks.audit.mockResolvedValue(undefined); +}); +it("rejects unauthorized access before touching the delivery", async () => { + mocks.permission.mockRejectedValue(new Error("Forbidden")); + await expect(retryDelivery(id)).rejects.toThrow("Forbidden"); + expect(mocks.retry).not.toHaveBeenCalled(); + expect(mocks.audit).not.toHaveBeenCalled(); +}); +it("reports a rejected retry without recording a successful action", async () => { + mocks.retry.mockRejectedValue(new Error("Database unavailable")); + expect(await retryDelivery(id)).toMatchObject({ ok: false }); + expect(mocks.audit).not.toHaveBeenCalled(); + expect(mocks.refresh).not.toHaveBeenCalled(); +}); +it("records the delivery UUID as audit metadata", async () => { + expect(await retryDelivery(id)).toMatchObject({ ok: true }); + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ userId: 7, after: { deliveryId: id } }), + ); +}); +it("does not report a committed retry as failed when audit and refresh fail", async () => { + mocks.audit.mockRejectedValue(new Error("Audit unavailable")); + mocks.refresh.mockImplementation(() => { + throw new Error("Refresh unavailable"); + }); + expect(await retryDelivery(id)).toMatchObject({ ok: true }); + expect(mocks.retry).toHaveBeenCalledTimes(1); + expect(mocks.error).toHaveBeenCalled(); +}); diff --git a/src/features/operations/retry-action.ts b/src/features/operations/retry-action.ts new file mode 100644 index 00000000..175fea6f --- /dev/null +++ b/src/features/operations/retry-action.ts @@ -0,0 +1,35 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { requirePermission } from "@/lib/admin/guard"; +import { logger } from "@/lib/logger"; +import { PERMS } from "@/lib/permission-slugs"; +import { logAudit } from "@/lib/services/audit"; +import { retryEffect } from "./server"; +export async function retryDelivery(id: string) { + const staff = await requirePermission(PERMS.DEVOPS_EDIT); + try { + await retryEffect(id); + } catch { + return { + ok: false as const, + error: "Delivery could not be retried. Refresh and try again.", + }; + } + await logAudit({ + userId: staff.id, + action: "operation_delivery_retry", + target: "operations", + after: { deliveryId: id }, + }).catch((error) => + logger.error("Delivery retried; audit failed", { + module: "operations", + error, + }), + ); + try { + revalidatePath("/admin/devops/deliveries"); + } catch { + /* Delivery retry remains saved. */ + } + return { ok: true as const, data: {} }; +} diff --git a/src/features/operations/retry-button.tsx b/src/features/operations/retry-button.tsx new file mode 100644 index 00000000..c0f5e054 --- /dev/null +++ b/src/features/operations/retry-button.tsx @@ -0,0 +1,23 @@ +"use client"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { useServerAction } from "@/hooks/use-server-action"; +import { retryDelivery } from "./retry-action"; +export function DeliveryRetry({ id }: { id: string }) { + const t = useTranslations("pages.admin.deliveries"); + const { run, isPending } = useServerAction(); + return ( + + ); +} diff --git a/src/features/operations/retry-effect.test.ts b/src/features/operations/retry-effect.test.ts new file mode 100644 index 00000000..5d3e0861 --- /dev/null +++ b/src/features/operations/retry-effect.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const execute = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/db", () => ({ db: { execute } })); + +import { retryEffect } from "./server"; + +const id = "840f4023-04cd-4d7b-92c5-047aa620029c"; +beforeEach(() => vi.resetAllMocks()); +it("does not report a missing or no-longer-failed delivery as retried", async () => { + execute.mockResolvedValue([{ affectedRows: 0 }]); + await expect(retryEffect(id)).rejects.toThrow("no longer available"); +}); +it("accepts a delivery that was actually moved back to pending", async () => { + execute.mockResolvedValue([{ affectedRows: 1 }]); + await expect(retryEffect(id)).resolves.toBeUndefined(); +}); +it("rejects malformed identifiers before a database query", async () => { + await expect( + retryEffect("------------------------------------"), + ).rejects.toThrow("Invalid delivery"); + expect(execute).not.toHaveBeenCalled(); +}); diff --git a/src/features/operations/server.ts b/src/features/operations/server.ts new file mode 100644 index 00000000..0abb91f8 --- /dev/null +++ b/src/features/operations/server.ts @@ -0,0 +1,117 @@ +import "server-only"; +import { randomUUID } from "node:crypto"; +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { + type EffectClaim, + type EffectTopic, + OperationConflict, + type OperationInput, + operationHash, + retryDelay, + validateOperation, +} from "./model"; +export type OperationTransaction = Parameters< + Parameters[0] +>[0]; +export async function runOperation( + input: OperationInput, + work: (tx: OperationTransaction, operationId: string) => Promise, +): Promise { + validateOperation(input); + const hash = operationHash(input.input); + return db.transaction(async (tx) => { + await tx.execute( + sql`INSERT INTO cms_operations (id,actor_id,kind,request_key,request_hash) VALUES (${randomUUID()},${input.actorId},${input.kind},${input.key},${hash}) ON DUPLICATE KEY UPDATE id=id`, + ); + const [rows] = await tx.execute( + sql`SELECT id,request_hash AS requestHash,result_json AS resultJson FROM cms_operations WHERE actor_id=${input.actorId} AND kind=${input.kind} AND request_key=${input.key} FOR UPDATE`, + ); + const row = ( + rows as unknown as Array<{ + id: string; + requestHash: string; + resultJson: string | null; + }> + )[0]; + if (!row) throw new Error("Operation unavailable"); + if (row.requestHash !== hash) throw new OperationConflict(); + if (row.resultJson !== null) return JSON.parse(row.resultJson) as T; + const result = await work(tx, row.id); + const serialized = JSON.stringify(result); + if (!serialized || Buffer.byteLength(serialized) > 1048576) + throw new Error("Operation result exceeds storage limit"); + await tx.execute( + sql`UPDATE cms_operations SET result_json=${serialized} WHERE id=${row.id}`, + ); + return result; + }); +} +export async function enqueueEffect( + tx: OperationTransaction, + operationId: string, + topic: EffectTopic, +) { + await tx.execute( + sql`INSERT INTO cms_outbox (id,operation_id,topic) VALUES (${randomUUID()},${operationId},${topic}) ON DUPLICATE KEY UPDATE id=id`, + ); +} +export const effectRepository = { + async claim(): Promise { + return db.transaction(async (tx) => { + const [rows] = await tx.execute( + sql`SELECT id,topic,attempts FROM cms_outbox WHERE (status='pending' AND available_at<=UTC_TIMESTAMP(3)) OR (status='running' AND lease_until + )[0]; + if (!row) return null; + const token = randomUUID(); + await tx.execute( + sql`UPDATE cms_outbox SET status='running',lease_token=${token},lease_until=DATE_ADD(UTC_TIMESTAMP(3),INTERVAL 120 SECOND),attempts=attempts+1 WHERE id=${row.id}`, + ); + return { ...row, token, attempts: Number(row.attempts) + 1 }; + }); + }, + async complete(claim: EffectClaim) { + await db.execute( + sql`UPDATE cms_outbox SET status='done',lease_token=NULL,lease_until=NULL,last_error=NULL WHERE id=${claim.id} AND status='running' AND lease_token=${claim.token}`, + ); + }, + async fail(claim: EffectClaim) { + await db.execute( + sql`UPDATE cms_outbox SET status=${claim.attempts >= 8 ? "failed" : "pending"},available_at=DATE_ADD(UTC_TIMESTAMP(3),INTERVAL ${retryDelay(claim.attempts)} SECOND),lease_token=NULL,lease_until=NULL,last_error='Delivery failed; inspect the linked operation and service diagnostics.' WHERE id=${claim.id} AND status='running' AND lease_token=${claim.token}`, + ); + }, +}; +export async function listEffects() { + const [rows] = await db.execute( + sql`SELECT e.id,e.operation_id AS operationId,e.topic,e.status,e.attempts,e.last_error AS lastError,e.created_at AS createdAt,o.actor_id AS actorId,o.kind FROM cms_outbox e JOIN cms_operations o ON o.id=e.operation_id ORDER BY e.created_at DESC,e.id DESC LIMIT 100`, + ); + return rows as unknown as Array<{ + id: string; + operationId: string; + topic: EffectTopic; + status: string; + attempts: number; + lastError: string | null; + createdAt: string; + actorId: number; + kind: string; + }>; +} +export async function retryEffect(id: string) { + if ( + !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(id) + ) + throw new Error("Invalid delivery"); + const [result] = await db.execute( + sql`UPDATE cms_outbox SET status='pending',attempts=0,available_at=UTC_TIMESTAMP(3),last_error=NULL WHERE id=${id} AND status='failed'`, + ); + if ((result as unknown as { affectedRows: number }).affectedRows !== 1) + throw new Error("Delivery is no longer available for retry"); +} diff --git a/src/features/operations/worker.ts b/src/features/operations/worker.ts new file mode 100644 index 00000000..972b7985 --- /dev/null +++ b/src/features/operations/worker.ts @@ -0,0 +1,32 @@ +import "server-only"; +import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; +import { logger } from "@/lib/logger"; +import { + catalogExportEnabled, + catalogExportQueue, +} from "@/lib/services/catalog-git-queue"; +import { dispatchEffects } from "./dispatcher"; +import { effectRepository } from "./server"; + +let running = false; +export async function drainOperationEffects() { + if (running) return; + running = true; + try { + await dispatchEffects(effectRepository, async (claim) => { + if (claim.topic === "catalog.refresh") { + if (!(await sendCatalogUpdate()).sent) + throw new Error("Hotel update not delivered"); + } else if (claim.topic === "catalog.export.request") { + if (catalogExportEnabled()) await catalogExportQueue().request(); + } else throw new Error("Unknown delivery topic"); + }); + } catch (error) { + logger.error("Operation delivery tick failed", { + module: "operations", + error, + }); + } finally { + running = false; + } +} diff --git a/src/messages/ar.json b/src/messages/ar.json index ce1417ed..c4512778 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -3534,6 +3534,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "myDashboard": { diff --git a/src/messages/bg.json b/src/messages/bg.json index 799237fd..7f4b9af6 100644 --- a/src/messages/bg.json +++ b/src/messages/bg.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/cs.json b/src/messages/cs.json index cc219e5d..e34c256a 100644 --- a/src/messages/cs.json +++ b/src/messages/cs.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/da.json b/src/messages/da.json index 55b666a2..38818d84 100644 --- a/src/messages/da.json +++ b/src/messages/da.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/de.json b/src/messages/de.json index 9cd97b3a..7fa4b1b1 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -4327,6 +4327,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "radioRequests": { diff --git a/src/messages/el.json b/src/messages/el.json index b51a2f66..4667a3ab 100644 --- a/src/messages/el.json +++ b/src/messages/el.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/en.json b/src/messages/en.json index 8a9c3f71..f30cef3e 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -4530,6 +4530,25 @@ "elapsed": "{seconds}s in this phase", "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "mod": { diff --git a/src/messages/es.json b/src/messages/es.json index 5c45f7b1..f4173768 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -4327,6 +4327,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "radioRequests": { diff --git a/src/messages/fi.json b/src/messages/fi.json index 5d741324..e1016d29 100644 --- a/src/messages/fi.json +++ b/src/messages/fi.json @@ -3534,6 +3534,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "myDashboard": { diff --git a/src/messages/fr.json b/src/messages/fr.json index 5548e40f..9da21d2e 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -4327,6 +4327,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "radioRequests": { diff --git a/src/messages/hr.json b/src/messages/hr.json index 8fffffbe..e7033fc5 100644 --- a/src/messages/hr.json +++ b/src/messages/hr.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/hu.json b/src/messages/hu.json index dda95239..0cdad538 100644 --- a/src/messages/hu.json +++ b/src/messages/hu.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/it.json b/src/messages/it.json index e577ca27..0d4ae66a 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -4499,6 +4499,25 @@ "elapsed": "{seconds}s in questa fase", "recovered": "Bundle recuperato da {source} · revisione {revision}. Classname, tipo e revisione corrispondenti; identità del bundle verificata.", "recoveryHint": "Puoi continuare. Se i file originali non sono disponibili, l’importatore cercherà nelle sorgenti attive lo stesso classname, tipo e revisione, verificando poi il bundle Nitro. Puoi comunque allegare manualmente i file mancanti." + }, + "deliveries": { + "title": "Consegne in sospeso", + "scope": "Ultime 100 consegne per modifiche massive al catalogo. La modifica al database è già salvata. Consegnata significa export accodato o richiesta inviata all’hotel, non applicazione confermata dai client. Il worker ritenta gli errori; qui puoi riavviare i tentativi esauriti.", + "refresh": "Aggiorna", + "empty": "Nessuna consegna registrata.", + "retry": "Riprova consegna", + "retried": "Consegna riaccodata.", + "retryError": "Consegna fallita. Controlla la diagnostica dei servizi prima di riprovare.", + "hotel": "Aggiornamento catalogo hotel", + "export": "Richiesta export Git catalogo", + "actor": "Staff #{id}", + "attempts": "{count} tentativi", + "states": { + "pending": "In attesa", + "running": "In consegna", + "done": "Consegnata", + "failed": "Da verificare" + } } }, "radioRequests": { diff --git a/src/messages/ja.json b/src/messages/ja.json index 898dc711..7cd76e6b 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -3534,6 +3534,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "myDashboard": { diff --git a/src/messages/nl.json b/src/messages/nl.json index 1afe4526..6685d350 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -4530,6 +4530,25 @@ "errors": "Open CMS error groups", "errorsHint": "From the retained diagnostic history", "unavailable": "Status unavailable — open to inspect" + }, + "deliveries": { + "title": "Openstaande leveringen", + "scope": "De laatste 100 leveringen voor bulkcataloguswijzigingen. De databasewijziging is al opgeslagen. Afgeleverd betekent dat export is ingepland of het hotelverzoek is verstuurd, niet dat clients het hebben toegepast. De worker probeert fouten opnieuw; uitgeputte pogingen kun je hier hervatten.", + "refresh": "Vernieuwen", + "empty": "Geen leveringen geregistreerd.", + "retry": "Opnieuw proberen", + "retried": "Levering opnieuw ingepland.", + "retryError": "Levering mislukt. Controleer de servicediagnostiek voordat je opnieuw probeert.", + "hotel": "Hotelcatalogus bijwerken", + "export": "Git-exportverzoek catalogus", + "actor": "Medewerker #{id}", + "attempts": "{count} pogingen", + "states": { + "pending": "Wachtend", + "running": "Wordt afgeleverd", + "done": "Afgeleverd", + "failed": "Aandacht nodig" + } } }, "mod": { diff --git a/src/messages/no.json b/src/messages/no.json index 0402a7d0..c5cfee55 100644 --- a/src/messages/no.json +++ b/src/messages/no.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/pl.json b/src/messages/pl.json index 18be916c..57903ad7 100644 --- a/src/messages/pl.json +++ b/src/messages/pl.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/pt.json b/src/messages/pt.json index 1d7d3888..82dcd423 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/ro.json b/src/messages/ro.json index a113ba9d..2b8708be 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/ru.json b/src/messages/ru.json index cf76f727..1c404d9b 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -4355,6 +4355,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/sk.json b/src/messages/sk.json index 51459194..ab1a27f4 100644 --- a/src/messages/sk.json +++ b/src/messages/sk.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/sr.json b/src/messages/sr.json index 389f4d0d..35c8c3cd 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/sv.json b/src/messages/sv.json index 99833a0e..13537b28 100644 --- a/src/messages/sv.json +++ b/src/messages/sv.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/tr.json b/src/messages/tr.json index f235ffa0..06789ac8 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": { diff --git a/src/messages/uk.json b/src/messages/uk.json index 4bbfa37e..a3a56fff 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -4357,6 +4357,25 @@ "linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.", "schedulePast": "The selected date is in the past. Review it before publishing.", "previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use." + }, + "deliveries": { + "title": "Pending deliveries", + "scope": "Latest 100 delivery records for bulk catalog edits. The database change is already committed. Done means the export was queued or the hotel update request was sent, not that clients applied it. The jobs worker retries failures; exhausted retries can be restarted here.", + "refresh": "Refresh", + "empty": "No delivery records.", + "retry": "Retry delivery", + "retried": "Delivery queued again.", + "retryError": "Delivery failed. Check service diagnostics before retrying.", + "hotel": "Hotel catalog update", + "export": "Catalog Git export request", + "actor": "Staff #{id}", + "attempts": "{count} attempts", + "states": { + "pending": "Waiting", + "running": "Delivering", + "done": "Delivered", + "failed": "Needs attention" + } } }, "error": {