Harden server draft sync (Save & Exit + post-login transfer)

This commit is contained in:
adilallo
2026-04-06 22:46:00 -06:00
parent b6b833e80f
commit a4f0b449b6
24 changed files with 457 additions and 102 deletions
+1 -2
View File
@@ -5,8 +5,7 @@ import ModalHeader from "../../utility/ModalHeader";
import type { LoginBackdropVariant, LoginViewProps } from "./Login.types";
const backdropClasses: Record<LoginBackdropVariant, string> = {
solid:
"bg-[var(--color-surface-inverse-brand-primary)]",
solid: "bg-[var(--color-surface-inverse-brand-primary)]",
blurredYellow:
"bg-[var(--color-surface-inverse-brand-primary)]/85 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--color-surface-inverse-brand-primary)]/75",
};
+1 -8
View File
@@ -113,14 +113,7 @@ export default function LoginForm({
} finally {
setSubmitting(false);
}
}, [
email,
isSaveProgress,
magicLinkNextPath,
nextParam,
stripErrorQuery,
t,
]);
}, [email, isSaveProgress, magicLinkNextPath, nextParam, stripErrorQuery, t]);
const urlErrorMessage =
errorParam === "expired_link"
+10 -13
View File
@@ -10,6 +10,7 @@ import {
import { useCreateFlow } from "./context/CreateFlowContext";
import { isValidStep } from "./utils/flowSteps";
import { saveDraftToServer } from "../../lib/create/api";
import messages from "../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
@@ -32,14 +33,14 @@ export function PostLoginDraftTransfer({
useEffect(() => {
if (sessionUser == null || sessionUser === undefined) return;
const wantsTransfer =
syncDraft === "1" || hasTransferPendingFlag();
const wantsTransfer = syncDraft === "1" || hasTransferPendingFlag();
if (!wantsTransfer) return;
if (attemptedRef.current) return;
if (!SYNC_ENABLED) {
if (attemptedRef.current) return;
attemptedRef.current = true;
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync-off path: show one-shot error then strip query
setTransferError(
"Saving to your account is not available (server sync is disabled). Your progress stays on this device.",
);
@@ -78,12 +79,15 @@ export function PostLoginDraftTransfer({
...(step ? { currentStep: step } : {}),
};
const ok = await saveDraftToServer(payload);
const saveResult = await saveDraftToServer(payload);
if (cancelled) return;
if (!ok) {
if (saveResult.ok === false) {
setTransferError(
"Could not save your draft to your account. Your progress is still stored on this device.",
messages.create.topNav.postLoginSaveFailedWithReason.replace(
"{reason}",
saveResult.message,
),
);
attemptedRef.current = false;
return;
@@ -103,14 +107,7 @@ export function PostLoginDraftTransfer({
return () => {
cancelled = true;
};
}, [
sessionUser,
pathname,
syncDraft,
replaceState,
router,
searchParams,
]);
}, [sessionUser, pathname, syncDraft, replaceState, router, searchParams]);
if (!transferError) return null;
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import type { CreateFlowState } from "./types";
import { createFlowStateHasKeys } from "../../lib/create/draftHydrationUtils";
import {
clearAnonymousCreateFlowStorage,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
} from "./anonymousDraftStorage";
import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../lib/create/api";
import messages from "../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
/**
* When sync is on and the user is signed in, fetch `GET /api/drafts/me` once and merge into context.
* Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer} owns that path.
*
* **Conflict:** If both server draft and `create-flow-anonymous` are non-empty, `window.confirm`
* chooses account draft (OK) vs browser copy (Cancel); browser storage is cleared after resolution.
*/
export function SignedInDraftHydration({
sessionUser,
sessionResolved,
}: {
sessionUser: { id: string; email: string } | null | undefined;
sessionResolved: boolean;
}) {
const searchParams = useSearchParams();
const syncDraftParam = searchParams.get("syncDraft");
const { replaceState, interactionTouched } = useCreateFlow();
const touchedRef = useRef(interactionTouched);
touchedRef.current = interactionTouched;
const [loadingHydration, setLoadingHydration] = useState(false);
const finishedUserIdRef = useRef<string | null>(null);
useEffect(() => {
if (!SYNC_ENABLED) return;
if (!sessionResolved) return;
if (sessionUser == null || sessionUser === undefined) {
finishedUserIdRef.current = null;
return;
}
const userId = sessionUser.id;
if (finishedUserIdRef.current === userId) return;
if (syncDraftParam === "1" || hasTransferPendingFlag()) {
finishedUserIdRef.current = userId;
return;
}
let cancelled = false;
setLoadingHydration(true);
void (async () => {
try {
const serverDraft = await fetchDraftFromServer();
if (cancelled) return;
const localDraft = readAnonymousCreateFlowState();
const hasServer =
serverDraft != null && createFlowStateHasKeys(serverDraft);
const hasLocal = createFlowStateHasKeys(localDraft);
if (touchedRef.current) {
finishedUserIdRef.current = userId;
return;
}
if (hasServer && hasLocal) {
const useAccount =
typeof window !== "undefined" &&
window.confirm(messages.create.draftHydration.conflictPrompt);
if (cancelled) return;
if (useAccount) {
replaceState(serverDraft as CreateFlowState);
} else {
replaceState(localDraft);
}
clearAnonymousCreateFlowStorage();
finishedUserIdRef.current = userId;
return;
}
if (hasServer) {
replaceState(serverDraft as CreateFlowState);
clearAnonymousCreateFlowStorage();
finishedUserIdRef.current = userId;
return;
}
if (hasLocal) {
replaceState(localDraft);
clearAnonymousCreateFlowStorage();
}
finishedUserIdRef.current = userId;
} finally {
if (!cancelled) setLoadingHydration(false);
}
})();
return () => {
cancelled = true;
};
}, [sessionResolved, sessionUser, syncDraftParam, replaceState]);
if (!loadingHydration) return null;
return (
<div
role="status"
aria-live="polite"
className="w-full shrink-0 px-[var(--spacing-measures-spacing-500,20px)] py-[var(--spacing-measures-spacing-200,8px)] md:px-[var(--measures-spacing-1800,64px)] text-center font-inter text-sm text-[var(--color-text-default-secondary,#a3a3a3)]"
>
{messages.create.draftHydration.loadingSavedProgress}
</div>
);
}
+5
View File
@@ -10,6 +10,11 @@ export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
export const CREATE_FLOW_TRANSFER_PENDING_KEY =
"create-flow-transfer-pending" as const;
/**
* When signed-in + sync, {@link SignedInDraftHydration} resolves server vs this key via `window.confirm`
* if both are non-empty; see `messages/en/create/draftHydration.json`.
*/
const LEGACY_LIVE_KEY = "create-flow-state";
const LEGACY_DRAFT_KEY = "create-flow-draft";
+2 -3
View File
@@ -63,9 +63,8 @@ export function CreateFlowProvider({
if (!wasOff) return;
const from = readAnonymousCreateFlowState();
if (Object.keys(from).length === 0) return;
setState((prev) =>
Object.keys(prev).length > 0 ? prev : { ...from },
);
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate anonymous draft when guest persistence turns on
setState((prev) => (Object.keys(prev).length > 0 ? prev : { ...from }));
}, [enableAnonymousPersistence]);
useEffect(() => {
@@ -0,0 +1,51 @@
"use client";
import {
createContext,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
type CreateFlowDraftSaveBannerContextValue = {
draftSaveBannerMessage: string | null;
setDraftSaveBannerMessage: (_message: string | null) => void;
};
const CreateFlowDraftSaveBannerContext =
createContext<CreateFlowDraftSaveBannerContextValue | null>(null);
export function CreateFlowDraftSaveBannerProvider({
children,
}: {
children: ReactNode;
}) {
const [draftSaveBannerMessage, setDraftSaveBannerMessage] = useState<
string | null
>(null);
const value = useMemo(
() => ({
draftSaveBannerMessage,
setDraftSaveBannerMessage,
}),
[draftSaveBannerMessage],
);
return (
<CreateFlowDraftSaveBannerContext.Provider value={value}>
{children}
</CreateFlowDraftSaveBannerContext.Provider>
);
}
export function useCreateFlowDraftSaveBanner(): CreateFlowDraftSaveBannerContextValue {
const ctx = useContext(CreateFlowDraftSaveBannerContext);
if (!ctx) {
throw new Error(
"useCreateFlowDraftSaveBanner must be used within CreateFlowDraftSaveBannerProvider",
);
}
return ctx;
}
+11 -8
View File
@@ -20,13 +20,16 @@ export function useCreateFlowExit({
clearState,
router,
user,
setDraftSaveBannerMessage,
}: {
state: CreateFlowState;
currentStep: CreateFlowStep | null;
clearState: CreateFlowExitClearState;
router: AppRouterLike;
user: { id: string; email: string } | null;
}): (options?: { saveDraft?: boolean }) => Promise<void> {
/** When save fails, surface the server message in the create shell banner (no leave confirm). */
setDraftSaveBannerMessage?: (_message: string | null) => void;
}): (_options?: { saveDraft?: boolean }) => Promise<void> {
return useCallback(
async (options?: { saveDraft?: boolean }) => {
if (!user) return;
@@ -45,18 +48,18 @@ export function useCreateFlowExit({
...state,
...(currentStep ? { currentStep } : {}),
};
const ok = await saveDraftToServer(payload);
if (!ok && typeof window !== "undefined") {
const leave = window.confirm(
messages.create.topNav.leaveConfirmSaveFailed,
);
if (!leave) return;
const result = await saveDraftToServer(payload);
if (result.ok === true) {
setDraftSaveBannerMessage?.(null);
} else {
setDraftSaveBannerMessage?.(result.message);
return;
}
}
clearState();
router.push("/");
},
[state, currentStep, clearState, router, user],
[state, currentStep, clearState, router, user, setDraftSaveBannerMessage],
);
}
+40 -19
View File
@@ -1,11 +1,6 @@
"use client";
import {
Suspense,
useEffect,
useState,
type ReactNode,
} from "react";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
@@ -15,8 +10,15 @@ import { getStepIndex } from "./utils/flowSteps";
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
import Button from "../components/buttons/Button";
import { fetchAuthSession } from "../../lib/create/api";
import messages from "../../messages/en/index";
import { useAuthModal } from "../contexts/AuthModalContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
import { SignedInDraftHydration } from "./SignedInDraftHydration";
import Alert from "../components/modals/Alert";
import {
CreateFlowDraftSaveBannerProvider,
useCreateFlowDraftSaveBanner,
} from "./context/CreateFlowDraftSaveBannerContext";
/** First step where Save & Exit is offered (after informational + name / `text`). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select");
@@ -37,19 +39,18 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
}, []);
const sessionResolved = sessionUser !== undefined;
const enableAnonymousPersistence =
sessionResolved && sessionUser === null;
const enableAnonymousPersistence = sessionResolved && sessionUser === null;
return (
<CreateFlowProvider
enableAnonymousPersistence={enableAnonymousPersistence}
>
<CreateFlowLayoutContent
sessionUser={sessionUser}
sessionResolved={sessionResolved}
>
{children}
</CreateFlowLayoutContent>
<CreateFlowProvider enableAnonymousPersistence={enableAnonymousPersistence}>
<CreateFlowDraftSaveBannerProvider>
<CreateFlowLayoutContent
sessionUser={sessionUser}
sessionResolved={sessionResolved}
>
{children}
</CreateFlowLayoutContent>
</CreateFlowDraftSaveBannerProvider>
</CreateFlowProvider>
);
}
@@ -74,6 +75,8 @@ function CreateFlowLayoutContent({
goToPreviousStep,
} = useCreateFlowNavigation();
const { state, clearState } = useCreateFlow();
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner();
const runAuthenticatedExit = useCreateFlowExit({
state,
@@ -81,6 +84,7 @@ function CreateFlowLayoutContent({
clearState,
router,
user: sessionUser ?? null,
setDraftSaveBannerMessage,
});
const handleExit = async (opts?: { saveDraft?: boolean }) => {
@@ -104,8 +108,7 @@ function CreateFlowLayoutContent({
const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "right-rail";
const useFullHeightMain = isCompletedStep || isRightRailStep;
const stepIdx =
currentStep != null ? getStepIndex(currentStep) : -1;
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
const saveDraftOnExit =
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
@@ -113,6 +116,24 @@ function CreateFlowLayoutContent({
<div
className={`bg-black flex flex-col ${useFullHeightMain ? "h-screen overflow-hidden" : "min-h-screen"}`}
>
{draftSaveBannerMessage ? (
<div className="w-full shrink-0 px-[var(--spacing-measures-spacing-500,20px)] pt-[var(--spacing-measures-spacing-300,12px)] md:px-[var(--measures-spacing-1800,64px)] z-[100]">
<Alert
type="banner"
status="danger"
title={messages.create.topNav.draftSaveBannerTitle}
description={draftSaveBannerMessage}
onClose={() => setDraftSaveBannerMessage(null)}
className="w-full max-w-[960px] mx-auto"
/>
</div>
) : null}
<Suspense fallback={null}>
<SignedInDraftHydration
sessionUser={sessionUser}
sessionResolved={sessionResolved}
/>
</Suspense>
<Suspense fallback={null}>
<PostLoginDraftTransfer sessionUser={sessionUser} />
</Suspense>
+1
View File
@@ -23,6 +23,7 @@ export default function TextPage() {
useEffect(() => {
const incoming = state.title;
if (typeof incoming !== "string" || incoming.length === 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync controlled field when context hydrates from server/local
setValue((prev) => (prev === "" ? incoming : prev));
}, [state.title]);
+1 -3
View File
@@ -7,9 +7,7 @@ import { fetchAuthSession, logout } from "../../lib/create/api";
export default function ProfilePageClient() {
const t = useTranslation("pages.profile");
const [user, setUser] = useState<{ id: string; email: string } | null>(
null,
);
const [user, setUser] = useState<{ id: string; email: string } | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
+29 -29
View File
@@ -173,7 +173,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
**Acceptance criteria:**
- [x] Completed step still works; **Save & Exit** gating uses session + step (not conflated with `completed` only).
- [x] Signed in + sync: Save & Exit persists server-side; anonymous: localStorage + exit modal + transfer after magic link. Sign out on profile clears session. *(Re-verify on staging/prod as needed.)*
- [x] Signed in + sync: Save & Exit persists server-side; anonymous: localStorage + exit modal + transfer after magic link. Sign out on profile clears session. _(Re-verify on staging/prod as needed.)_
**Files:** [app/create/layout.tsx](app/create/layout.tsx), [app/create/hooks/useCreateFlowExit.ts](app/create/hooks/useCreateFlowExit.ts), [app/components/utility/CreateFlowTopNav/](app/components/utility/CreateFlowTopNav/), [app/create/context/CreateFlowContext.tsx](app/create/context/CreateFlowContext.tsx), [messages/en/create/topNav.json](messages/en/create/topNav.json), [app/profile/ProfilePageClient.tsx](app/profile/ProfilePageClient.tsx).
@@ -185,22 +185,22 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
**Goal:** Server draft **PUT** path is production-grade when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` (Save & Exit, post-login transfer from anonymous draft).
**Context:** Auto-hydrate / debounced autosave component was removed; create flow starts fresh for signed-in users until profile “open draft” (future). Residual risks: silent **PUT** failure (confirm on exit today), richer error surfaces.
**Context:** Auto-hydrate / debounced autosave component was removed; signed-in resume uses `GET /api/drafts/me` in the create layout.
**Implementation:**
1. **Hydration:** Show a non-blocking “Loading your saved progress…” until first session + draft fetch completes (only when sync enabled).
2. **Conflict:** If `localStorage` has non-empty state and server returns non-empty draft, pick a policy: prefer server with confirm modal, or prefer newer `updatedAt` (requires storing timestamp client-side). Document choice in code comment.
3. **Save failures (API surface):** Change [saveDraftToServer](lib/create/api.ts) from `Promise<boolean>` to a result type such as `{ ok: true } | { ok: false; message: string; status?: number }`, parsing the response body with [readApiErrorMessage](lib/create/api.ts) so both legacy `{ error: string }` and CR-73 validation `{ error: { message } }` (and 413 `payload_too_large`) produce a useful `message`. Use that result in [useCreateFlowExit](app/create/hooks/useCreateFlowExit.ts) and [PostLoginDraftTransfer](app/create/PostLoginDraftTransfer.tsx).
4. **Save failures (UX):** On `ok: false`, show toast/banner (include `message`); optionally retry with backoff.
5. **Tests:** Component test or Playwright scenario with sync flag on (may require test DB or route mocks).
1. **Hydration:** **Done:** [SignedInDraftHydration](app/create/SignedInDraftHydration.tsx) + [messages/en/create/draftHydration.json](messages/en/create/draftHydration.json); skips `?syncDraft=1` / transfer-pending (PostLogin owns that). Wired in [layout](app/create/layout.tsx).
2. **Conflict:** **Done:** If `create-flow-anonymous` and server draft are both non-empty, `window.confirm` (OK = account draft, Cancel = browser copy); documented on [anonymousDraftStorage](app/create/anonymousDraftStorage.ts). Newer-`updatedAt` client compare remains optional.
3. **Save failures (API surface):** **Done (CR-76):** [saveDraftToServer](lib/create/api.ts) returns `SaveDraftResult` with parsed API `message`; wired in [useCreateFlowExit](app/create/hooks/useCreateFlowExit.ts) and [PostLoginDraftTransfer](app/create/PostLoginDraftTransfer.tsx).
4. **Save failures (UX):** **Done (CR-76):** Dismissible banner with server `message` (no second confirm to leave); post-login transfer shows reason; unit tests in `tests/unit/saveDraftToServer.test.ts`. Retry/backoff remains optional.
5. **Tests:** `saveDraftToServer` unit tests; [draftHydrationUtils](lib/create/draftHydrationUtils.ts) unit tests. Playwright against Next standalone + route mocks for `/api/auth/session` was flaky here; cover hydration with **manual QA** (signed in + sync on + server draft) or add a future E2E with a dedicated auth fixture.
**Acceptance criteria:**
- [ ] No silent data loss when server save fails.
- [ ] User understands when server draft replaced local state (if applicable).
- [x] No silent data loss when server save fails (user sees reason in banner; stays in flow to retry Save & Exit or leave via e.g. logo).
- [x] User understands when server draft replaced local state (if applicable) — conflict `window.confirm` when both browser anonymous draft and account draft exist; otherwise silent apply of single source.
**Files:** [lib/create/api.ts](lib/create/api.ts), [app/create/hooks/useCreateFlowExit.ts](app/create/hooks/useCreateFlowExit.ts), [app/create/PostLoginDraftTransfer.tsx](app/create/PostLoginDraftTransfer.tsx), possibly [CreateFlowContext](app/create/context/CreateFlowContext.tsx), tests under `tests/`.
**Files:** [lib/create/api.ts](lib/create/api.ts), [app/create/hooks/useCreateFlowExit.ts](app/create/hooks/useCreateFlowExit.ts), [app/create/PostLoginDraftTransfer.tsx](app/create/PostLoginDraftTransfer.tsx), [app/create/SignedInDraftHydration.tsx](app/create/SignedInDraftHydration.tsx), [app/create/layout.tsx](app/create/layout.tsx), [CreateFlowContext](app/create/context/CreateFlowContext.tsx), tests under `tests/`.
---
@@ -508,7 +508,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
| 13 | 13 | API errors + request-id logging |
| 14 | 14 | Session lifecycle + cleanup |
| 15 | 15 | Profile + account (Figma profile) |
| 16 | 16 | Template matrix + xlsx ingestion |
| 16 | 16 | Template matrix + xlsx ingestion |
Tickets **1011** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 16** is also **deferrable** until after **78** (flat template list + UI); it adds **spreadsheet-driven** recommendations and facet APIs. **Tickets 1314** are parallel to that chain (**CR-73** / **CR-75** prerequisites are **Done****CR-84** / **CR-85** are unblocked), not sequential after CR-83. **Ticket 15** is also **parallel** (blocked by **publish (CR-77)** once session/auth are shipped); Linear: **CR-86**.
@@ -518,24 +518,24 @@ Tickets **1011** can be deferred without blocking the core “auth + drafts +
**Main chain:** **CR-72 → CR-83** (each blocks the next). **Parallel:** **CR-84** (**CR-73** Done — ready to pick up), **CR-85** (**CR-75** Done — ready to pick up), **CR-86** / Ticket 15 (blocked by **CR-77** publish only; **CR-75** Done), **CR-88** / Ticket 16 (template matrix + `.xlsx` ingestion — after **CR-78**/**CR-79**), not in the CR-7283 sequence.
| Doc ticket | Linear | Title (short) |
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| 1 | [CR-72](https://linear.app/community-rule/issue/CR-72/backend-align-docsbackend-roadmapmd-with-current-codebase) | Align backend-roadmap |
| 2 | [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) | CreateFlowState + API validation |
| 3 | [CR-74](https://linear.app/community-rule/issue/CR-74/backend-magic-link-sign-in-ui-apis-ticket-3-cr-75-done) | Magic-link sign-in UI (Ticket 3; Done) |
| 4 | [CR-75](https://linear.app/community-rule/issue/CR-75/backend-create-flow-session-ui-sign-out-ticket-4-done) | Create flow session UI (Ticket 4; Done)|
| 5 | [CR-76](https://linear.app/community-rule/issue/CR-76/backend-harden-server-draft-sync-save-and-exit-post-login-transfer) | Draft sync hardening (PUT UX / errors) |
| 6 | [CR-77](https://linear.app/community-rule/issue/CR-77/backend-wire-publish-rule-from-create-flow-post-apirules) | Publish wiring |
| 7 | [CR-78](https://linear.app/community-rule/issue/CR-78/backend-prisma-seed-ruletemplate-document) | Template seed |
| 8 | [CR-79](https://linear.app/community-rule/issue/CR-79/backend-load-rule-templates-from-get-apitemplates-in-ui) | Templates in UI |
| 9 | [CR-80](https://linear.app/community-rule/issue/CR-80/backend-persist-web-vitals-outside-next-db-or-external-rum) | Web vitals (prefer external) |
| 10 | [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) | Public rule detail (optional) |
| 11 | [CR-82](https://linear.app/community-rule/issue/CR-82/backend-ci-postgres-migration-smoke-optional) | CI migrate smoke (optional) |
| 12 | [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) | Ops runbook / admin handoff |
| 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging |
| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup |
| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) |
| 16 | [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) | Template matrix + xlsx ingestion |
| Doc ticket | Linear | Title (short) |
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| 1 | [CR-72](https://linear.app/community-rule/issue/CR-72/backend-align-docsbackend-roadmapmd-with-current-codebase) | Align backend-roadmap |
| 2 | [CR-73](https://linear.app/community-rule/issue/CR-73/backend-formalize-createflowstate-validate-draftpublish-api-payloads) | CreateFlowState + API validation |
| 3 | [CR-74](https://linear.app/community-rule/issue/CR-74/backend-magic-link-sign-in-ui-apis-ticket-3-cr-75-done) | Magic-link sign-in UI (Ticket 3; Done) |
| 4 | [CR-75](https://linear.app/community-rule/issue/CR-75/backend-create-flow-session-ui-sign-out-ticket-4-done) | Create flow session UI (Ticket 4; Done) |
| 5 | [CR-76](https://linear.app/community-rule/issue/CR-76/backend-harden-server-draft-sync-save-and-exit-post-login-transfer) | Draft sync hardening (PUT UX / errors) |
| 6 | [CR-77](https://linear.app/community-rule/issue/CR-77/backend-wire-publish-rule-from-create-flow-post-apirules) | Publish wiring |
| 7 | [CR-78](https://linear.app/community-rule/issue/CR-78/backend-prisma-seed-ruletemplate-document) | Template seed |
| 8 | [CR-79](https://linear.app/community-rule/issue/CR-79/backend-load-rule-templates-from-get-apitemplates-in-ui) | Templates in UI |
| 9 | [CR-80](https://linear.app/community-rule/issue/CR-80/backend-persist-web-vitals-outside-next-db-or-external-rum) | Web vitals (prefer external) |
| 10 | [CR-81](https://linear.app/community-rule/issue/CR-81/backend-public-rule-detail-page-get-apirulesid-optional) | Public rule detail (optional) |
| 11 | [CR-82](https://linear.app/community-rule/issue/CR-82/backend-ci-postgres-migration-smoke-optional) | CI migrate smoke (optional) |
| 12 | [CR-83](https://linear.app/community-rule/issue/CR-83/backend-stagingproduction-runbook-admin-handoff-docsops-backend) | Ops runbook / admin handoff |
| 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging |
| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup |
| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) |
| 16 | [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion) | Template matrix + xlsx ingestion |
---
+1 -1
View File
@@ -80,7 +80,7 @@ Plain-English entities (names can evolve):
| **MagicLinkToken** | Short-lived **hashed** token for email sign-in links; optional `nextPath` for post-login redirect. |
| **RuleDraft** | **One** JSON blob per user (create-flow state). Schema already has **`updatedAt`**; no draft **versioning** or **multiple named drafts** in v1. |
| **PublishedRule** | Saved rule after publish (title, summary, document JSON). Profile UI badges such as **IN PROGRESS** may be **derived from `document` JSON**, a future `status` column, or UI-only—product decision when implementing Ticket 15. |
| **RuleTemplate** | Curated templates (slug, category, ordering, `body` JSON). **v1 API** lists rows for cards / create entry; **not** yet a recommendation engine (see below). |
| **RuleTemplate** | Curated templates (slug, category, ordering, `body` JSON). **v1 API** lists rows for cards / create entry; **not** yet a recommendation engine (see below). |
**RuleTemplate — recommendation matrix (after v1 list):** Product may author templates in **spreadsheets** (e.g. one row per governance pattern, columns for **matching dimensions** such as group size, organization type, location, maturity, plus long-form fields for create-flow prefill). That implies: **normalized schema or versioned JSON** for dimensions × template fit (✓/✗, weights, or scores), an **import path** (export `.xlsx` / Sheets → validate → DB or build-time artifact), and **`GET /api/templates` (or a sibling route)** that accepts **user- or wizard-selected facets** and returns a **ranked or filtered** set. **Out of scope for first ship** of Tickets 78 (seed + display list); tracked as **Ticket 16** in [docs/backend-linear-tickets.md](backend-linear-tickets.md) and Linear **[CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion)**. Prefer **batch import** over live Google Sheets API in production unless ops explicitly wants sync.
+43 -8
View File
@@ -80,16 +80,51 @@ export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
return data.draft.payload as CreateFlowState;
}
const DRAFT_SAVE_NETWORK_ERROR =
"Something went wrong. Check your connection and try again.";
export type SaveDraftResult =
| { ok: true }
| { ok: false; message: string; status?: number };
async function errorBodyMessage(res: Response): Promise<string> {
try {
const data: unknown = await res.json();
const msg = readApiErrorMessage(data);
if (msg !== "Request failed") return msg;
} catch {
/* non-JSON body */
}
const statusText = res.statusText?.trim();
if (statusText) return statusText;
return "Save failed";
}
export async function saveDraftToServer(
state: CreateFlowState,
): Promise<boolean> {
const res = await fetch("/api/drafts/me", {
method: "PUT",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ payload: state }),
});
return res.ok;
): Promise<SaveDraftResult> {
try {
const res = await fetch("/api/drafts/me", {
method: "PUT",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ payload: state }),
});
if (res.ok) {
return { ok: true as const };
}
const message = await errorBodyMessage(res);
return {
ok: false as const,
message,
status: res.status,
};
} catch {
return {
ok: false as const,
message: DRAFT_SAVE_NETWORK_ERROR,
};
}
}
export async function publishRule(input: {
+6
View File
@@ -0,0 +1,6 @@
import type { CreateFlowState } from "../../app/create/types";
/** True when the client should treat a draft payload as non-empty for hydration / conflict checks. */
export function createFlowStateHasKeys(state: CreateFlowState): boolean {
return Object.keys(state).length > 0;
}
+4
View File
@@ -0,0 +1,4 @@
{
"loadingSavedProgress": "Loading your saved progress…",
"conflictPrompt": "You have progress saved in this browser and a draft on your account.\n\nOK — load the account draft (discard the browser copy).\nCancel — keep this browser copy."
}
+2 -1
View File
@@ -2,5 +2,6 @@
"saveAndExit": "Save & Exit",
"exit": "Exit",
"leaveConfirmLoss": "Leave create flow? Your progress will be lost.",
"leaveConfirmSaveFailed": "Could not save to your account. Leave anyway?"
"draftSaveBannerTitle": "Couldn't save draft",
"postLoginSaveFailedWithReason": "Could not save your draft to your account. Your progress is still stored on this device.\n\n{reason}"
}
+2
View File
@@ -19,6 +19,7 @@ import navigation from "./navigation.json";
import metadata from "./metadata.json";
import communication from "./create/communication.json";
import createTopNav from "./create/topNav.json";
import createDraftHydration from "./create/draftHydration.json";
export default {
common,
@@ -43,6 +44,7 @@ export default {
create: {
communication,
topNav: createTopNav,
draftHydration: createDraftHydration,
},
navigation,
metadata,
+4 -4
View File
@@ -48,8 +48,8 @@ function FakeMarketingPageBehindOverlay({
/>
<div className="relative z-0 px-8 py-16">
<p className="font-inter max-w-md text-lg text-neutral-800">
Placeholder page content the login overlay portals above this and uses
backdrop blur (`blurredYellow`).
Placeholder page content the login overlay portals above this and
uses backdrop blur (`blurredYellow`).
</p>
</div>
{children}
@@ -71,7 +71,7 @@ export default {
docs: {
description: {
component:
"**Primary UX:** `AuthModalProvider` opens this as a **popup overlay** on top of the current page — `backdropVariant=\"blurredYellow\"`, `usePortal` (default). **`/login`** is a thin full-page shell: yellow **solid** backdrop, `usePortal={false}`, same `LoginForm` inside.",
'**Primary UX:** `AuthModalProvider` opens this as a **popup overlay** on top of the current page — `backdropVariant="blurredYellow"`, `usePortal` (default). **`/login`** is a thin full-page shell: yellow **solid** backdrop, `usePortal={false}`, same `LoginForm` inside.',
},
},
},
@@ -92,7 +92,7 @@ export const HeaderOverlayBlurred = {
docs: {
description: {
story:
"Same as **Log in** from the site header: `backdropVariant=\"blurredYellow\"`, `usePortal`, card + “Back to home” below.",
'Same as **Log in** from the site header: `backdropVariant="blurredYellow"`, `usePortal`, card + “Back to home” below.',
},
},
},
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { Suspense } from "react";
import { Suspense } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { Suspense } from "react";
import { Suspense } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+13
View File
@@ -0,0 +1,13 @@
import { describe, it, expect } from "vitest";
import { createFlowStateHasKeys } from "../../lib/create/draftHydrationUtils";
describe("createFlowStateHasKeys", () => {
it("returns false for empty object", () => {
expect(createFlowStateHasKeys({})).toBe(false);
});
it("returns true when any key is present", () => {
expect(createFlowStateHasKeys({ title: "x" })).toBe(true);
expect(createFlowStateHasKeys({ currentStep: "text" })).toBe(true);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { saveDraftToServer } from "../../lib/create/api";
import type { CreateFlowState } from "../../app/create/types";
const minimalState: CreateFlowState = {};
describe("saveDraftToServer", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns ok true on 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ draft: { payload: {}, updatedAt: "" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const result = await saveDraftToServer(minimalState);
expect(result).toEqual({ ok: true });
});
it("returns message from validation error body", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: { code: "validation_error", message: "Payload invalid" },
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
),
);
const result = await saveDraftToServer(minimalState);
expect(result).toEqual({
ok: false,
message: "Payload invalid",
status: 400,
});
});
it("returns message from 413 payload_too_large", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: {
code: "payload_too_large",
message: "Request body must be at most 524288 bytes",
},
}),
{ status: 413, headers: { "Content-Type": "application/json" } },
),
);
const result = await saveDraftToServer(minimalState);
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.message).toContain("524288");
expect(result.status).toBe(413);
}
});
it("returns Unauthorized string from 401 legacy shape", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
const result = await saveDraftToServer(minimalState);
expect(result).toEqual({
ok: false,
message: "Unauthorized",
status: 401,
});
});
it("falls back when error body is not JSON", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
new Response("not json", {
status: 500,
statusText: "Internal Server Error",
}),
);
const result = await saveDraftToServer(minimalState);
expect(result).toEqual({
ok: false,
message: "Internal Server Error",
status: 500,
});
});
it("returns network message when fetch rejects", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("offline"));
const result = await saveDraftToServer(minimalState);
expect(result).toEqual({
ok: false,
message: "Something went wrong. Check your connection and try again.",
});
});
});