feat(catalog): persist idempotent bulk operations and retryable deliveries
This commit is contained in:
1 parent
76e46d295c
commit
13665e0c3c
53 files changed
+1316
-76
No files matched your search
@@ -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.
|
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.
|
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.
|
||||||
@@ -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;
|
||||||
@@ -4,6 +4,7 @@ import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import type { RowDataPacket } from "mysql2/promise";
|
import type { RowDataPacket } from "mysql2/promise";
|
||||||
import mysql from "mysql2/promise";
|
import mysql from "mysql2/promise";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ const migrations = [
|
|||||||
"0027_catalog_packages.sql",
|
"0027_catalog_packages.sql",
|
||||||
"0028_history_snapshots.sql",
|
"0028_history_snapshots.sql",
|
||||||
"0029_admin_table_views.sql",
|
"0029_admin_table_views.sql",
|
||||||
|
"0031_operations_outbox.sql",
|
||||||
];
|
];
|
||||||
let maria: StartedTestContainer | undefined;
|
let maria: StartedTestContainer | undefined;
|
||||||
let redisContainer: StartedTestContainer | undefined;
|
let redisContainer: StartedTestContainer | undefined;
|
||||||
@@ -25,6 +27,7 @@ let connection: mysql.Connection | undefined;
|
|||||||
let appDb: typeof import("@/lib/db").db | undefined;
|
let appDb: typeof import("@/lib/db").db | undefined;
|
||||||
let appRedis: typeof import("@/lib/redis").redis;
|
let appRedis: typeof import("@/lib/redis").redis;
|
||||||
let commands: typeof import("@/features/catalog/server/bulk-offers");
|
let commands: typeof import("@/features/catalog/server/bulk-offers");
|
||||||
|
let operations: typeof import("@/features/operations/server");
|
||||||
let cache: typeof import("@/lib/cache");
|
let cache: typeof import("@/lib/cache");
|
||||||
let migrationRoot: string | undefined;
|
let migrationRoot: string | undefined;
|
||||||
let databaseUrl: string;
|
let databaseUrl: string;
|
||||||
@@ -131,6 +134,7 @@ beforeAll(async () => {
|
|||||||
await appRedis.ping();
|
await appRedis.ping();
|
||||||
commands = await import("@/features/catalog/server/bulk-offers");
|
commands = await import("@/features/catalog/server/bulk-offers");
|
||||||
cache = await import("@/lib/cache");
|
cache = await import("@/lib/cache");
|
||||||
|
operations = await import("@/features/operations/server");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -159,6 +163,8 @@ afterAll(async () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
if (!connection) throw Error("Integration database is not connected");
|
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("DROP TRIGGER IF EXISTS reject_second_history");
|
||||||
await connection.query("DELETE FROM admin_audit_log");
|
await connection.query("DELETE FROM admin_audit_log");
|
||||||
await connection.query("DELETE FROM catalog_items");
|
await connection.query("DELETE FROM catalog_items");
|
||||||
@@ -195,7 +201,7 @@ describe("MariaDB migrations and catalog transactions", () => {
|
|||||||
{ name: "Saved" },
|
{ name: "Saved" },
|
||||||
]);
|
]);
|
||||||
expect((await migrate("--status")).stdout).toContain(
|
expect((await migrate("--status")).stdout).toContain(
|
||||||
"3/3 applied, 0 pending",
|
"4/4 applied, 0 pending",
|
||||||
);
|
);
|
||||||
const columns = await rows(
|
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",
|
"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"');
|
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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { drainOperationEffects } from "../src/features/operations/worker";
|
||||||
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
||||||
import "./load-env";
|
import "./load-env";
|
||||||
import { Cron } from "croner";
|
import { Cron } from "croner";
|
||||||
@@ -381,6 +382,10 @@ async function reportWorkerHeartbeat(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
new Cron("* * * * *", () => {
|
||||||
|
void drainOperationEffects();
|
||||||
|
});
|
||||||
|
void drainOperationEffects();
|
||||||
new Cron("* * * * *", () => {
|
new Cron("* * * * *", () => {
|
||||||
void drainFurnitureImports();
|
void drainFurnitureImports();
|
||||||
});
|
});
|
||||||
|
|||||||
+59
-29
@@ -8,51 +8,52 @@ import {
|
|||||||
undoBulkOffersCommand,
|
undoBulkOffersCommand,
|
||||||
} from "@/features/catalog/server/bulk-offers";
|
} from "@/features/catalog/server/bulk-offers";
|
||||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
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 { requirePermission } from "@/lib/admin/guard";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||||
export async function previewBulkOffers(input: BulkOfferInput) {
|
export async function previewBulkOffers(input: BulkOfferInput) {
|
||||||
await requirePermission(PERMS.CATALOG_VIEW);
|
await requirePermission(PERMS.CATALOG_VIEW);
|
||||||
try {
|
try {
|
||||||
return { ok: true as const, data: await previewBulkOffersCommand(input) };
|
return { ok: true as const, data: await previewBulkOffersCommand(input) };
|
||||||
} catch (error) {
|
} 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(
|
export async function applyBulkOffers(
|
||||||
input: BulkOfferInput,
|
input: BulkOfferInput,
|
||||||
fingerprint: string,
|
fingerprint: string,
|
||||||
|
requestKey?: string,
|
||||||
) {
|
) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
try {
|
try {
|
||||||
return await withCatalogExport(async () => {
|
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) {
|
if (data.changedCount > 0) {
|
||||||
await logAudit({
|
refreshCatalog();
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
return { ok: true as const, data };
|
return { ok: true as const, data };
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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(),
|
data: await listBulkOfferDestinationsCommand(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
try {
|
try {
|
||||||
return await withCatalogExport(async () => {
|
return await withCatalogExport(async () => {
|
||||||
const data = await undoBulkOffersCommand(historyIds, staff.id);
|
const data = await undoBulkOffersCommand(
|
||||||
await sendCatalogUpdate();
|
historyIds,
|
||||||
revalidatePath("/admin/catalog", "layout");
|
staff.id,
|
||||||
revalidatePath("/admin/logs/audit");
|
requestKey,
|
||||||
|
);
|
||||||
|
refreshCatalog();
|
||||||
return { ok: true as const, data };
|
return { ok: true as const, data };
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className="min-w-0 space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("scope")}</p>
|
||||||
|
<Link href="/admin/devops/deliveries" className="btn btn-outline">
|
||||||
|
{t("refresh")}
|
||||||
|
</Link>
|
||||||
|
{!items.length ? (
|
||||||
|
<p className="admin-card">{t("empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="admin-card min-w-0 space-y-2">
|
||||||
|
<div className="flex flex-wrap justify-between gap-3">
|
||||||
|
<strong>
|
||||||
|
{t(item.topic === "catalog.refresh" ? "hotel" : "export")}
|
||||||
|
</strong>
|
||||||
|
<span>{t(`states.${item.status}`)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="break-all text-sm">
|
||||||
|
{item.operationId} · {t("actor", { id: item.actorId })} ·{" "}
|
||||||
|
{t("attempts", { count: item.attempts })}
|
||||||
|
</p>
|
||||||
|
{item.status === "failed" && <p>{t("retryError")}</p>}
|
||||||
|
{editable && item.status === "failed" && (
|
||||||
|
<DeliveryRetry id={item.id} />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ export default async function DevOpsPage() {
|
|||||||
const t = await getTranslations("pages.admin.devops");
|
const t = await getTranslations("pages.admin.devops");
|
||||||
const data = await getDevOpsData();
|
const data = await getDevOpsData();
|
||||||
const performance = await getTranslations("pages.admin.performance");
|
const performance = await getTranslations("pages.admin.performance");
|
||||||
|
const deliveries = await getTranslations("pages.admin.deliveries");
|
||||||
const installation = await getTranslations("pages.admin.installation");
|
const installation = await getTranslations("pages.admin.installation");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,6 +68,9 @@ export default async function DevOpsPage() {
|
|||||||
<Link href="/admin/devops/performance" className="btn btn-outline">
|
<Link href="/admin/devops/performance" className="btn btn-outline">
|
||||||
{performance("title")}
|
{performance("title")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/admin/devops/deliveries" className="btn btn-outline">
|
||||||
|
{deliveries("title")}
|
||||||
|
</Link>
|
||||||
{/* Status cards */}
|
{/* Status cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function BulkOfferEditor({
|
|||||||
const [preview, setPreview] = useState<{
|
const [preview, setPreview] = useState<{
|
||||||
input: BulkInput;
|
input: BulkInput;
|
||||||
data: Preview;
|
data: Preview;
|
||||||
|
requestKey: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const busyRef = useRef(false);
|
const busyRef = useRef(false);
|
||||||
@@ -149,7 +150,7 @@ export function BulkOfferEditor({
|
|||||||
setError(result.error);
|
setError(result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPreview({ input, data: result.data });
|
setPreview({ input, data: result.data, requestKey: crypto.randomUUID() });
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("requestFailed"));
|
setError(t("requestFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -172,12 +173,14 @@ export function BulkOfferEditor({
|
|||||||
const result = await applyBulkOffers(
|
const result = await applyBulkOffers(
|
||||||
preview.input,
|
preview.input,
|
||||||
preview.data.fingerprint,
|
preview.data.fingerprint,
|
||||||
|
preview.requestKey,
|
||||||
);
|
);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const undoKey = crypto.randomUUID();
|
||||||
toast.success(t("success", { count: result.data.changedCount }), {
|
toast.success(t("success", { count: result.data.changedCount }), {
|
||||||
duration: 15000,
|
duration: 15000,
|
||||||
description: t("undoHint"),
|
description: t("undoHint"),
|
||||||
@@ -188,7 +191,10 @@ export function BulkOfferEditor({
|
|||||||
if (busyRef.current || (beforeEdit && !beforeEdit())) return;
|
if (busyRef.current || (beforeEdit && !beforeEdit())) return;
|
||||||
busyRef.current = true;
|
busyRef.current = true;
|
||||||
try {
|
try {
|
||||||
const restored = await undoBulkOffers(result.data.historyIds);
|
const restored = await undoBulkOffers(
|
||||||
|
result.data.historyIds,
|
||||||
|
undoKey,
|
||||||
|
);
|
||||||
if (!restored.ok) {
|
if (!restored.ok) {
|
||||||
toast.error(restored.error);
|
toast.error(restored.error);
|
||||||
return;
|
return;
|
||||||
@@ -210,7 +216,6 @@ export function BulkOfferEditor({
|
|||||||
await onApplied();
|
await onApplied();
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("requestFailed"));
|
setError(t("requestFailed"));
|
||||||
setPreview(null);
|
|
||||||
} finally {
|
} finally {
|
||||||
busyRef.current = false;
|
busyRef.current = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|||||||
@@ -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((await applyBulkOffers(input, "x")).ok).toBe(true);
|
||||||
expect(state.permission).toHaveBeenCalledWith("edit");
|
expect(state.permission).toHaveBeenCalledWith("edit");
|
||||||
expect(state.export).toHaveBeenCalledTimes(1);
|
expect(state.export).toHaveBeenCalledTimes(1);
|
||||||
expect(state.send).toHaveBeenCalledTimes(1);
|
expect(state.send).not.toHaveBeenCalled();
|
||||||
expect(state.apply.mock.invocationCallOrder[0]).toBeLessThan(
|
expect(state.refresh).toHaveBeenCalled();
|
||||||
state.send.mock.invocationCallOrder[0],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
it("stops denied permission before export or data access", async () => {
|
it("stops denied permission before export or data access", async () => {
|
||||||
state.permission.mockRejectedValueOnce(Error("Denied"));
|
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 () => {
|
it("does not report a committed edit as failed on audit failure", async () => {
|
||||||
state.audit.mockRejectedValueOnce(Error("log failed"));
|
state.audit.mockRejectedValueOnce(Error("log failed"));
|
||||||
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
|
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 () => {
|
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 });
|
state.undo.mockResolvedValueOnce({ changedCount: 2 });
|
||||||
expect((await undoBulkOffers([10, 11])).ok).toBe(true);
|
expect((await undoBulkOffers([10, 11])).ok).toBe(true);
|
||||||
expect(state.permission).toHaveBeenCalledWith("edit");
|
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.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 () => {
|
it("undo denial stops before export and a conflict does not notify", async () => {
|
||||||
state.permission.mockRejectedValueOnce(Error("Denied"));
|
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((await undoBulkOffers([10])).ok).toBe(false);
|
||||||
expect(state.send).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
@@ -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<unknown>,
|
||||||
|
) => db.transaction((tx) => work(tx, "operation-fixture")),
|
||||||
|
enqueueEffect: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
applyBulkOffersCommand,
|
applyBulkOffersCommand,
|
||||||
listBulkOfferDestinationsCommand,
|
listBulkOfferDestinationsCommand,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
applyHistory,
|
applyHistory,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
readHistory,
|
readHistory,
|
||||||
recordHistory,
|
recordHistory,
|
||||||
} from "@/features/history/server";
|
} from "@/features/history/server";
|
||||||
|
import { enqueueEffect, runOperation } from "@/features/operations/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
type BulkOfferInput,
|
type BulkOfferInput,
|
||||||
@@ -115,11 +116,12 @@ export async function applyBulkOffersCommand(
|
|||||||
value: BulkOfferInput,
|
value: BulkOfferInput,
|
||||||
fingerprint: string,
|
fingerprint: string,
|
||||||
userId?: number,
|
userId?: number,
|
||||||
|
requestKey: string = randomUUID(),
|
||||||
) {
|
) {
|
||||||
const input = bulkOfferInputSchema.parse(value);
|
const input = bulkOfferInputSchema.parse(value);
|
||||||
if (!/^[a-f0-9]{64}$/.test(fingerprint))
|
if (!/^[a-f0-9]{64}$/.test(fingerprint))
|
||||||
throw new CatalogInputError("A valid preview is required");
|
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.
|
// Read references, then lock pages before offers to match category structural commands.
|
||||||
const initial = await readOffers(tx, input.ids);
|
const initial = await readOffers(tx, input.ids);
|
||||||
const pages = await readPages(tx, initial, input, true);
|
const pages = await readPages(tx, initial, input, true);
|
||||||
@@ -167,8 +169,27 @@ export async function applyBulkOffersCommand(
|
|||||||
historyIds.push(historyId);
|
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 { 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() {
|
export async function listBulkOfferDestinationsCommand() {
|
||||||
@@ -188,6 +209,7 @@ export async function listBulkOfferDestinationsCommand() {
|
|||||||
export async function undoBulkOffersCommand(
|
export async function undoBulkOffersCommand(
|
||||||
historyIds: number[],
|
historyIds: number[],
|
||||||
userId: number,
|
userId: number,
|
||||||
|
requestKey: string = randomUUID(),
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
!Array.isArray(historyIds) ||
|
!Array.isArray(historyIds) ||
|
||||||
@@ -197,27 +219,39 @@ export async function undoBulkOffersCommand(
|
|||||||
historyIds.some((id) => !Number.isSafeInteger(id) || id < 1)
|
historyIds.some((id) => !Number.isSafeInteger(id) || id < 1)
|
||||||
)
|
)
|
||||||
throw new CatalogInputError("Invalid undo selection");
|
throw new CatalogInputError("Invalid undo selection");
|
||||||
return db.transaction(async (tx) => {
|
return runOperation(
|
||||||
const entries = [];
|
{
|
||||||
for (const id of historyIds) {
|
actorId: userId,
|
||||||
const entry = await readHistory(tx, id);
|
kind: "catalog.bulk.undo",
|
||||||
if (entry.kind !== "catalog_offer")
|
key: requestKey,
|
||||||
throw new CatalogInputError("Invalid undo selection");
|
input: { historyIds: [...historyIds].sort((a, b) => a - b) },
|
||||||
entries.push(entry);
|
},
|
||||||
}
|
async (tx, operationId) => {
|
||||||
entries.sort((a, b) => Number(a.targetId) - Number(b.targetId));
|
const entries = [];
|
||||||
if (new Set(entries.map((entry) => entry.targetId)).size !== entries.length)
|
for (const id of historyIds) {
|
||||||
throw new CatalogInputError("Duplicate undo target");
|
const entry = await readHistory(tx, id);
|
||||||
await lockOfferHistoryPages(tx, entries);
|
if (entry.kind !== "catalog_offer")
|
||||||
try {
|
throw new CatalogInputError("Invalid undo selection");
|
||||||
for (const entry of entries) await applyHistory(tx, entry, userId);
|
entries.push(entry);
|
||||||
} catch (error) {
|
}
|
||||||
if (error instanceof Error && error.message === "conflict")
|
entries.sort((a, b) => Number(a.targetId) - Number(b.targetId));
|
||||||
throw new CatalogConflict(
|
if (
|
||||||
"Offers changed after this update. Undo was not applied.",
|
new Set(entries.map((entry) => entry.targetId)).size !== entries.length
|
||||||
);
|
)
|
||||||
throw error;
|
throw new CatalogInputError("Duplicate undo target");
|
||||||
}
|
await lockOfferHistoryPages(tx, entries);
|
||||||
return { changedCount: entries.length };
|
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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -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<unknown>,
|
||||||
|
) => db.transaction((tx) => work(tx, "operation-fixture")),
|
||||||
|
enqueueEffect: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { undoBulkOffersCommand } from "./bulk-offers";
|
import { undoBulkOffersCommand } from "./bulk-offers";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -605,15 +605,15 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
expect(html).toContain(`>${sentinel}</button>`);
|
expect(html).toContain(`>${sentinel}</button>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validates the complete 142-row migration matrix without issues", () => {
|
it("validates the complete 143-row migration matrix without issues", () => {
|
||||||
const discovered = discoverLegacyPages();
|
const discovered = discoverLegacyPages();
|
||||||
const issues = validateMigrationEntries(
|
const issues = validateMigrationEntries(
|
||||||
discovered,
|
discovered,
|
||||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(142);
|
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(143);
|
||||||
expect(discovered).toHaveLength(142);
|
expect(discovered).toHaveLength(143);
|
||||||
expect(issues).toEqual([]);
|
expect(issues).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
|
|||||||
it("discovers the exact legacy administration inventory", () => {
|
it("discovers the exact legacy administration inventory", () => {
|
||||||
const pages = discoverLegacyPages();
|
const pages = discoverLegacyPages();
|
||||||
|
|
||||||
expect(pages).toHaveLength(142);
|
expect(pages).toHaveLength(143);
|
||||||
expect(pages).toContainEqual({
|
expect(pages).toContainEqual({
|
||||||
surface: "admin",
|
surface: "admin",
|
||||||
legacyPath: "/admin/users/:id/edit",
|
legacyPath: "/admin/users/:id/edit",
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
|||||||
import { validateMigrationEntries } from "./validate-matrix";
|
import { validateMigrationEntries } from "./validate-matrix";
|
||||||
|
|
||||||
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
||||||
it("covers all 142 legacy pages exactly once", () => {
|
it("covers all 143 legacy pages exactly once", () => {
|
||||||
const discovered = discoverLegacyPages();
|
const discovered = discoverLegacyPages();
|
||||||
|
|
||||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(142);
|
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(143);
|
||||||
expect(
|
expect(
|
||||||
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
describe("systemMigrationEntries", () => {
|
describe("systemMigrationEntries", () => {
|
||||||
it("covers all 22 System pages exactly once", () => {
|
it("covers all 23 System pages exactly once", () => {
|
||||||
expect(systemMigrationEntries).toHaveLength(22);
|
expect(systemMigrationEntries).toHaveLength(23);
|
||||||
expect(
|
expect(
|
||||||
validateMigrationEntries(
|
validateMigrationEntries(
|
||||||
ownedLegacyPages(SYSTEM_PREFIXES),
|
ownedLegacyPages(SYSTEM_PREFIXES),
|
||||||
|
|||||||
@@ -58,6 +58,24 @@ export const systemMigrationEntries: readonly MigrationEntry[] = [
|
|||||||
localization: "PARTIAL",
|
localization: "PARTIAL",
|
||||||
accessibility: "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.
|
// Operator alerts and read-only analytics.
|
||||||
plannedSystemEntry({
|
plannedSystemEntry({
|
||||||
surface: "admin",
|
surface: "admin",
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { EffectClaim } from "./model";
|
||||||
|
export interface EffectRepository {
|
||||||
|
claim(): Promise<EffectClaim | null>;
|
||||||
|
complete(claim: EffectClaim): Promise<unknown>;
|
||||||
|
fail(claim: EffectClaim): Promise<unknown>;
|
||||||
|
}
|
||||||
|
/** At-least-once delivery: handlers must tolerate replay after an expired claim. */
|
||||||
|
export async function dispatchEffects(
|
||||||
|
repo: EffectRepository,
|
||||||
|
deliver: (claim: EffectClaim) => Promise<unknown>,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<string, unknown>)[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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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: {} };
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() =>
|
||||||
|
run(() => retryDelivery(id), {
|
||||||
|
successMessage: t("retried"),
|
||||||
|
errorMessage: t("retryError"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("retry")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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<typeof db.transaction>[0]
|
||||||
|
>[0];
|
||||||
|
export async function runOperation<T>(
|
||||||
|
input: OperationInput,
|
||||||
|
work: (tx: OperationTransaction, operationId: string) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
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<EffectClaim | null> {
|
||||||
|
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<UTC_TIMESTAMP(3)) ORDER BY available_at,id LIMIT 1 FOR UPDATE`,
|
||||||
|
);
|
||||||
|
const row = (
|
||||||
|
rows as unknown as Array<{
|
||||||
|
id: string;
|
||||||
|
topic: EffectTopic;
|
||||||
|
attempts: number;
|
||||||
|
}>
|
||||||
|
)[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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3534,6 +3534,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"myDashboard": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4327,6 +4327,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"radioRequests": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4530,6 +4530,25 @@
|
|||||||
"elapsed": "{seconds}s in this phase",
|
"elapsed": "{seconds}s in this phase",
|
||||||
"recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.",
|
"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."
|
"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": {
|
"mod": {
|
||||||
|
|||||||
@@ -4327,6 +4327,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"radioRequests": {
|
||||||
|
|||||||
@@ -3534,6 +3534,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"myDashboard": {
|
||||||
|
|||||||
@@ -4327,6 +4327,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"radioRequests": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4499,6 +4499,25 @@
|
|||||||
"elapsed": "{seconds}s in questa fase",
|
"elapsed": "{seconds}s in questa fase",
|
||||||
"recovered": "Bundle recuperato da {source} · revisione {revision}. Classname, tipo e revisione corrispondenti; identità del bundle verificata.",
|
"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."
|
"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": {
|
"radioRequests": {
|
||||||
|
|||||||
@@ -3534,6 +3534,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"myDashboard": {
|
||||||
|
|||||||
@@ -4530,6 +4530,25 @@
|
|||||||
"errors": "Open CMS error groups",
|
"errors": "Open CMS error groups",
|
||||||
"errorsHint": "From the retained diagnostic history",
|
"errorsHint": "From the retained diagnostic history",
|
||||||
"unavailable": "Status unavailable — open to inspect"
|
"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": {
|
"mod": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4355,6 +4355,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
@@ -4357,6 +4357,25 @@
|
|||||||
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
|
"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.",
|
"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."
|
"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": {
|
"error": {
|
||||||
|
|||||||
Reference in new issue
Block a user