feat(catalog): persist idempotent bulk operations and retryable deliveries
CI / check (push) Successful in 3m7s
CI / deploy (push) Successful in 1m37s
CI / publish-container (push) Successful in 44s

This commit is contained in:
Simo committed 2026-09-13 18:05:03 +02:00
1 parent 76e46d295c
commit 13665e0c3c
53 files changed
+1296 -56

No files matched your search

+2 -2
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+5
View File
@@ -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();
}); });
File diff suppressed because it is too large. Load diff
+47
View File
@@ -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>
);
}
+4
View File
@@ -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,
+40 -6
View File
@@ -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,7 +219,14 @@ 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(
{
actorId: userId,
kind: "catalog.bulk.undo",
key: requestKey,
input: { historyIds: [...historyIds].sort((a, b) => a - b) },
},
async (tx, operationId) => {
const entries = []; const entries = [];
for (const id of historyIds) { for (const id of historyIds) {
const entry = await readHistory(tx, id); const entry = await readHistory(tx, id);
@@ -206,7 +235,9 @@ export async function undoBulkOffersCommand(
entries.push(entry); entries.push(entry);
} }
entries.sort((a, b) => Number(a.targetId) - Number(b.targetId)); entries.sort((a, b) => Number(a.targetId) - Number(b.targetId));
if (new Set(entries.map((entry) => entry.targetId)).size !== entries.length) if (
new Set(entries.map((entry) => entry.targetId)).size !== entries.length
)
throw new CatalogInputError("Duplicate undo target"); throw new CatalogInputError("Duplicate undo target");
await lockOfferHistoryPages(tx, entries); await lockOfferHistoryPages(tx, entries);
try { try {
@@ -218,6 +249,9 @@ export async function undoBulkOffersCommand(
); );
throw error; throw error;
} }
await enqueueEffect(tx, operationId, "catalog.refresh");
await enqueueEffect(tx, operationId, "catalog.export.request");
return { changedCount: entries.length }; 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",
+9
View File
@@ -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);
});
+23
View File
@@ -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);
}
}
+31
View File
@@ -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);
});
+62
View File
@@ -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();
});
+35
View File
@@ -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: {} };
}
+23
View File
@@ -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();
});
File diff suppressed because it is too large. Load diff
+32
View File
@@ -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;
}
}
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {