+
+ Download every board from every workspace as one readable JSON file. Decryption happens in
+ this browser, so the server never sees the plaintext.
+
+ {locked ? (
+
+ Your vault is locked. Open a board once to unlock it, then come back.
+
+ ) : (
+
+
+
+ )}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
/** DSGVO Art. 17 — crypto-shred + scheduled hard delete (#46). */
function DangerSection() {
const router = useRouter();
diff --git a/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx b/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx
index 97e2892..6d518c0 100644
--- a/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx
+++ b/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx
@@ -14,7 +14,7 @@ import { useKeyBundle } from "@/stores/key-bundle";
import { useBoardLive } from "@/hooks/use-board-live";
import { useBoardPresence } from "@/hooks/use-board-presence";
import { loadBoardSnapshot, saveBoardSnapshot } from "@/lib/idb/board-cache";
-import { queuedFetch } from "@/lib/offline/sync";
+import { queueOp, queuedFetch } from "@/lib/offline/sync";
import {
downloadCsvExport,
downloadExport,
@@ -836,6 +836,15 @@ export function BoardClient({
const t = templates.find((x) => x.id === templateId);
if (!t) return;
+ // Every id in the chain is minted up front (ADR 0023). That is what makes
+ // the whole template applicable offline: previously each POST had to wait
+ // for the previous response to learn the id it needed, so nothing could be
+ // queued. With the ids known in advance the ops are independent, and
+ // flushOutbox replays them oldest-first, so a child never lands before its
+ // parent. Every create endpoint in the chain upserts idempotently, so a
+ // partial replay is safe to repeat.
+ const newCardId = crypto.randomUUID();
+
const cardMeta: CardMeta = { title: t.body.title };
if (t.body.description) cardMeta.description = t.body.description;
const env = encryptToEnvelope({
@@ -843,89 +852,123 @@ export function BoardClient({
payload: cardMeta,
bindTo: "card:new",
});
- const res = await fetch(`/api/columns/${columnId}/cards`, {
+ const { queued, res } = await queuedFetch(currentUserId, {
+ boardId,
+ label: `Create card from template "${t.body.title}"`,
method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ enc_content: env.serialised }),
+ url: `/api/columns/${columnId}/cards`,
+ body: { id: newCardId, enc_content: env.serialised },
});
- if (!res.ok) return;
- const createdCard = (await res.json()) as ApiCard;
+ if (!queued && !(res && res.ok)) return;
- // Append optimistically with zero checklist items; we'll patch the
- // progress after all the items have been posted.
+ const createdCard = !queued && res ? ((await res.json()) as ApiCard) : null;
+
+ // Once any link of the chain is queued, every later one must be queued
+ // too, even if the browser still reports itself online. Otherwise a
+ // transient failure on the card POST would queue it while the checklist
+ // POST fired directly and 404'd, because the card does not exist server
+ // side yet. queuedFetch's per-card ordering guard cannot cover this: the
+ // card create posts to /api/columns/