feat(hk): explain background updates with protected content links and retry details
CI / check (push) Failing after 1m34s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-13 19:41:25 +02:00
1 parent dd7613850e
commit c5c9941768
42 files changed
+1542 -145

No files matched your search

+9 -2
View File
@@ -1,9 +1,16 @@
# Durable catalog operations
# Durable operations and background updates
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.
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. Adapters cover bulk offer apply/undo and manual/scheduled news cache refresh, 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.
The delivery screen separates saved content from its subsequent background update. New news operations persist only the article identifier and title alongside the existing result; no article body is stored for display. Article IDs remain strings to preserve bigint precision. Older records retain readable status and technical references even when no content metadata exists. Catalog operations show affected-offer counts; furniture import progress remains in the linked import history.
Content titles and links require the corresponding NEWS_VIEW or CATALOG_VIEW grant in addition to DEVOPS_VIEW. Raw result JSON and internal error text are never forwarded to the card. Each record shows its recorded time, staff/system actor, attempts, and scheduled retry time when applicable. Failures point to service diagnostics; exhausted attempts expose the existing permission-protected retry without repeating the content mutation. Delivery success does not imply Git publication or client refresh acknowledgement.
Browser fixtures verify the real cards on desktop/mobile, including long titles, denied metadata, exact links, unknown states and collapsed technical references. The fixture retry slot does not execute a backend action; backend authorization and state changes have separate tests.
+105
View File
@@ -0,0 +1,105 @@
import { useFormatter, useTranslations } from "next-intl";
import type { ReactNode } from "react";
import Link from "@/components/link";
import { deliveryTopic } from "./delivery-context";
export interface DeliveryCardItem {
id: string;
operationId: string;
topic: string;
status: string;
attempts: number;
actorId: number;
createdAt: string;
availableAt: string;
hasError: boolean;
context: {
title: string | null;
href: string | null;
changedCount: number | null;
};
}
export function DeliveryCard({
item,
retry,
}: {
item: DeliveryCardItem;
retry?: ReactNode;
}) {
const t = useTranslations("pages.admin.deliveries");
const format = useFormatter();
const topic = deliveryTopic(item.topic);
const state = ["pending", "running", "done", "failed"].includes(item.status)
? item.status
: "unknown";
const date = (value: string) => {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? t("unknownTime")
: format.dateTime(parsed, { dateStyle: "medium", timeStyle: "short" });
};
return (
<article className="admin-card min-w-0 space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="text-sm text-[var(--admin-text-muted)]">{t(topic)}</p>
<h2 className="break-words text-lg font-semibold">
{item.context.title ??
(item.context.changedCount !== null
? t("changedOffers", { count: item.context.changedCount })
: t("contextUnavailable"))}
</h2>
</div>
<span className="rounded-full border border-[var(--admin-border)] px-3 py-1 text-sm">
{t(`states.${state}`)}
</span>
</div>
<p className="text-sm text-[var(--admin-text-muted)]">
{date(item.createdAt)} ·{" "}
{item.actorId === 0 ? t("system") : t("actor", { id: item.actorId })} ·{" "}
{t("attempts", { count: item.attempts })}
</p>
{state === "done" ? (
<p className="text-sm">{t(`completed.${topic}`)}</p>
) : (
<p className="text-sm">{t("savedAlready")}</p>
)}
{item.hasError && state !== "done" && (
<div
className="rounded-lg border border-[var(--admin-border)] p-3 text-sm"
role="status"
>
<p>{t(`issues.${topic}`)}</p>
{state === "pending" && (
<p className="mt-1">
{t("nextAttempt", { time: date(item.availableAt) })}
</p>
)}
{state === "failed" && (
<p className="mt-1">{t("attemptsExhausted")}</p>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
{item.context.href && (
<Link href={item.context.href} className="btn btn-outline">
{t("openContent")}
</Link>
)}
{item.hasError && state !== "done" && (
<Link href="/admin/devops/installation" className="btn btn-outline">
{t("diagnostics")}
</Link>
)}
{retry}
</div>
<details className="text-xs text-[var(--admin-text-muted)]">
<summary className="cursor-pointer">{t("technicalDetails")}</summary>
<p className="mt-2 break-all">
{t("operationReference", { id: item.operationId })}
</p>
<p className="break-all">{t("deliveryReference", { id: item.id })}</p>
</details>
</article>
);
}
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { deliveryContext, deliveryTopic } from "./delivery-context";
describe("delivery context", () => {
it("links a news record without rounding a bigint identifier", () => {
expect(
deliveryContext(
"news.update",
JSON.stringify({
articleId: "9007199254740993",
articleTitle: "A community update",
}),
{ news: true, catalog: false },
),
).toEqual({
title: "A community update",
href: "/admin/articles/9007199254740993",
changedCount: null,
});
});
it("does not expose news metadata without news permission", () => {
expect(
deliveryContext(
"news.create",
JSON.stringify({
articleTitle: "Private draft",
articleSlug: "private",
}),
{ news: false, catalog: true },
),
).toEqual({ title: null, href: null, changedCount: null });
});
it("supports searchable new articles and legacy scheduled records", () => {
expect(
deliveryContext(
"news.create",
JSON.stringify({
articleTitle: "Hello & welcome",
articleSlug: "hello",
}),
{ news: true, catalog: false },
).href,
).toBe("/admin/articles?search=Hello+%26+welcome");
expect(
deliveryContext(
"news.schedule.publish",
'{"articleId":"42","published":true}',
{ news: true, catalog: false },
).href,
).toBe("/admin/articles/42");
});
it.each([
null,
"{broken",
"[]",
'{"articleId":"../../settings","articleTitle":42}',
'"value"',
])("handles missing or invalid historical context: %s", (result) => {
expect(
deliveryContext("news.update", result, { news: true, catalog: false }),
).toEqual({ title: null, href: "/admin/articles", changedCount: null });
});
it("exposes only known bounded catalog counts with catalog permission", () => {
expect(
deliveryContext("catalog.bulk.apply", '{"changedCount":8}', {
news: false,
catalog: true,
}),
).toEqual({ title: null, href: "/admin/catalog", changedCount: 8 });
expect(
deliveryContext("catalog.bulk.apply", '{"changedCount":8}', {
news: true,
catalog: false,
}).changedCount,
).toBeNull();
expect(
deliveryContext("catalog.bulk.undo", '{"changedCount":-1}', {
news: false,
catalog: true,
}).changedCount,
).toBeNull();
});
it("does not interpret unknown operations or topics as export", () => {
expect(
deliveryContext("future.operation", '{"articleTitle":"secret"}', {
news: true,
catalog: true,
}),
).toEqual({ title: null, href: null, changedCount: null });
expect(deliveryTopic("unknown.topic")).toBe("unknown");
expect(deliveryTopic("news.refresh")).toBe("news");
});
});
@@ -0,0 +1,56 @@
/** Public presentation data only: never forward a stored operation payload to the browser. */
export function deliveryContext(
kind: string,
serialized: string | null,
access: { news: boolean; catalog: boolean },
): { title: string | null; href: string | null; changedCount: number | null } {
const context = {
title: null as string | null,
href: null as string | null,
changedCount: null as number | null,
};
let result: Record<string, unknown> = {};
if (serialized && serialized.length <= 1_048_576) {
try {
const parsed: unknown = JSON.parse(serialized);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
result = parsed as Record<string, unknown>;
} catch {
/* Older records may not contain presentation metadata. */
}
}
if (
["news.create", "news.update", "news.schedule.publish"].includes(kind) &&
access.news
) {
context.href = "/admin/articles";
if (typeof result.articleTitle === "string" && result.articleTitle.trim()) {
context.title = result.articleTitle.trim().slice(0, 255);
context.href = `/admin/articles?${new URLSearchParams({ search: context.title.slice(0, 191) })}`;
}
if (
typeof result.articleId === "string" &&
/^[1-9][0-9]{0,19}$/.test(result.articleId)
)
context.href = `/admin/articles/${result.articleId}`;
} else if (
["catalog.bulk.apply", "catalog.bulk.undo"].includes(kind) &&
access.catalog
) {
context.href = "/admin/catalog";
if (
typeof result.changedCount === "number" &&
Number.isSafeInteger(result.changedCount) &&
result.changedCount >= 0 &&
result.changedCount <= 500
)
context.changedCount = result.changedCount;
}
return context;
}
export function deliveryTopic(topic: string) {
if (topic === "catalog.refresh") return "hotel";
if (topic === "catalog.export.request") return "export";
if (topic === "news.refresh") return "news";
return "unknown";
}
+4 -2
View File
@@ -90,7 +90,7 @@ export const effectRepository = {
};
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`,
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,e.available_at AS availableAt,o.actor_id AS actorId,o.kind,o.result_json AS resultJson 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;
@@ -99,7 +99,9 @@ export async function listEffects() {
status: string;
attempts: number;
lastError: string | null;
createdAt: string;
createdAt: Date | string;
availableAt: Date | string;
resultJson: string | null;
actorId: number;
kind: string;
}>;