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"; import type { LoginBackdropVariant, LoginViewProps } from "./Login.types";
const backdropClasses: Record<LoginBackdropVariant, string> = { const backdropClasses: Record<LoginBackdropVariant, string> = {
solid: solid: "bg-[var(--color-surface-inverse-brand-primary)]",
"bg-[var(--color-surface-inverse-brand-primary)]",
blurredYellow: blurredYellow:
"bg-[var(--color-surface-inverse-brand-primary)]/85 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--color-surface-inverse-brand-primary)]/75", "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 { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, [ }, [email, isSaveProgress, magicLinkNextPath, nextParam, stripErrorQuery, t]);
email,
isSaveProgress,
magicLinkNextPath,
nextParam,
stripErrorQuery,
t,
]);
const urlErrorMessage = const urlErrorMessage =
errorParam === "expired_link" errorParam === "expired_link"
+10 -13
View File
@@ -10,6 +10,7 @@ import {
import { useCreateFlow } from "./context/CreateFlowContext"; import { useCreateFlow } from "./context/CreateFlowContext";
import { isValidStep } from "./utils/flowSteps"; import { isValidStep } from "./utils/flowSteps";
import { saveDraftToServer } from "../../lib/create/api"; import { saveDraftToServer } from "../../lib/create/api";
import messages from "../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
@@ -32,14 +33,14 @@ export function PostLoginDraftTransfer({
useEffect(() => { useEffect(() => {
if (sessionUser == null || sessionUser === undefined) return; if (sessionUser == null || sessionUser === undefined) return;
const wantsTransfer = const wantsTransfer = syncDraft === "1" || hasTransferPendingFlag();
syncDraft === "1" || hasTransferPendingFlag();
if (!wantsTransfer) return; if (!wantsTransfer) return;
if (attemptedRef.current) return; if (attemptedRef.current) return;
if (!SYNC_ENABLED) { if (!SYNC_ENABLED) {
if (attemptedRef.current) return; if (attemptedRef.current) return;
attemptedRef.current = true; attemptedRef.current = true;
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync-off path: show one-shot error then strip query
setTransferError( setTransferError(
"Saving to your account is not available (server sync is disabled). Your progress stays on this device.", "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 } : {}), ...(step ? { currentStep: step } : {}),
}; };
const ok = await saveDraftToServer(payload); const saveResult = await saveDraftToServer(payload);
if (cancelled) return; if (cancelled) return;
if (!ok) { if (saveResult.ok === false) {
setTransferError( 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; attemptedRef.current = false;
return; return;
@@ -103,14 +107,7 @@ export function PostLoginDraftTransfer({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [ }, [sessionUser, pathname, syncDraft, replaceState, router, searchParams]);
sessionUser,
pathname,
syncDraft,
replaceState,
router,
searchParams,
]);
if (!transferError) return null; 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 = export const CREATE_FLOW_TRANSFER_PENDING_KEY =
"create-flow-transfer-pending" as const; "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_LIVE_KEY = "create-flow-state";
const LEGACY_DRAFT_KEY = "create-flow-draft"; const LEGACY_DRAFT_KEY = "create-flow-draft";
+2 -3
View File
@@ -63,9 +63,8 @@ export function CreateFlowProvider({
if (!wasOff) return; if (!wasOff) return;
const from = readAnonymousCreateFlowState(); const from = readAnonymousCreateFlowState();
if (Object.keys(from).length === 0) return; if (Object.keys(from).length === 0) return;
setState((prev) => // eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate anonymous draft when guest persistence turns on
Object.keys(prev).length > 0 ? prev : { ...from }, setState((prev) => (Object.keys(prev).length > 0 ? prev : { ...from }));
);
}, [enableAnonymousPersistence]); }, [enableAnonymousPersistence]);
useEffect(() => { 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, clearState,
router, router,
user, user,
setDraftSaveBannerMessage,
}: { }: {
state: CreateFlowState; state: CreateFlowState;
currentStep: CreateFlowStep | null; currentStep: CreateFlowStep | null;
clearState: CreateFlowExitClearState; clearState: CreateFlowExitClearState;
router: AppRouterLike; router: AppRouterLike;
user: { id: string; email: string } | null; 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( return useCallback(
async (options?: { saveDraft?: boolean }) => { async (options?: { saveDraft?: boolean }) => {
if (!user) return; if (!user) return;
@@ -45,18 +48,18 @@ export function useCreateFlowExit({
...state, ...state,
...(currentStep ? { currentStep } : {}), ...(currentStep ? { currentStep } : {}),
}; };
const ok = await saveDraftToServer(payload); const result = await saveDraftToServer(payload);
if (!ok && typeof window !== "undefined") { if (result.ok === true) {
const leave = window.confirm( setDraftSaveBannerMessage?.(null);
messages.create.topNav.leaveConfirmSaveFailed, } else {
); setDraftSaveBannerMessage?.(result.message);
if (!leave) return; return;
} }
} }
clearState(); clearState();
router.push("/"); router.push("/");
}, },
[state, currentStep, clearState, router, user], [state, currentStep, clearState, router, user, setDraftSaveBannerMessage],
); );
} }
+34 -13
View File
@@ -1,11 +1,6 @@
"use client"; "use client";
import { import { Suspense, useEffect, useState, type ReactNode } from "react";
Suspense,
useEffect,
useState,
type ReactNode,
} from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
@@ -15,8 +10,15 @@ import { getStepIndex } from "./utils/flowSteps";
import CreateFlowFooter from "../components/utility/CreateFlowFooter"; import CreateFlowFooter from "../components/utility/CreateFlowFooter";
import Button from "../components/buttons/Button"; import Button from "../components/buttons/Button";
import { fetchAuthSession } from "../../lib/create/api"; import { fetchAuthSession } from "../../lib/create/api";
import messages from "../../messages/en/index";
import { useAuthModal } from "../contexts/AuthModalContext"; import { useAuthModal } from "../contexts/AuthModalContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; 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`). */ /** First step where Save & Exit is offered (after informational + name / `text`). */
const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select"); const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select");
@@ -37,19 +39,18 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
}, []); }, []);
const sessionResolved = sessionUser !== undefined; const sessionResolved = sessionUser !== undefined;
const enableAnonymousPersistence = const enableAnonymousPersistence = sessionResolved && sessionUser === null;
sessionResolved && sessionUser === null;
return ( return (
<CreateFlowProvider <CreateFlowProvider enableAnonymousPersistence={enableAnonymousPersistence}>
enableAnonymousPersistence={enableAnonymousPersistence} <CreateFlowDraftSaveBannerProvider>
>
<CreateFlowLayoutContent <CreateFlowLayoutContent
sessionUser={sessionUser} sessionUser={sessionUser}
sessionResolved={sessionResolved} sessionResolved={sessionResolved}
> >
{children} {children}
</CreateFlowLayoutContent> </CreateFlowLayoutContent>
</CreateFlowDraftSaveBannerProvider>
</CreateFlowProvider> </CreateFlowProvider>
); );
} }
@@ -74,6 +75,8 @@ function CreateFlowLayoutContent({
goToPreviousStep, goToPreviousStep,
} = useCreateFlowNavigation(); } = useCreateFlowNavigation();
const { state, clearState } = useCreateFlow(); const { state, clearState } = useCreateFlow();
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner();
const runAuthenticatedExit = useCreateFlowExit({ const runAuthenticatedExit = useCreateFlowExit({
state, state,
@@ -81,6 +84,7 @@ function CreateFlowLayoutContent({
clearState, clearState,
router, router,
user: sessionUser ?? null, user: sessionUser ?? null,
setDraftSaveBannerMessage,
}); });
const handleExit = async (opts?: { saveDraft?: boolean }) => { const handleExit = async (opts?: { saveDraft?: boolean }) => {
@@ -104,8 +108,7 @@ function CreateFlowLayoutContent({
const isCompletedStep = currentStep === "completed"; const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "right-rail"; const isRightRailStep = currentStep === "right-rail";
const useFullHeightMain = isCompletedStep || isRightRailStep; const useFullHeightMain = isCompletedStep || isRightRailStep;
const stepIdx = const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
currentStep != null ? getStepIndex(currentStep) : -1;
const saveDraftOnExit = const saveDraftOnExit =
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX; Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
@@ -113,6 +116,24 @@ function CreateFlowLayoutContent({
<div <div
className={`bg-black flex flex-col ${useFullHeightMain ? "h-screen overflow-hidden" : "min-h-screen"}`} 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}> <Suspense fallback={null}>
<PostLoginDraftTransfer sessionUser={sessionUser} /> <PostLoginDraftTransfer sessionUser={sessionUser} />
</Suspense> </Suspense>
+1
View File
@@ -23,6 +23,7 @@ export default function TextPage() {
useEffect(() => { useEffect(() => {
const incoming = state.title; const incoming = state.title;
if (typeof incoming !== "string" || incoming.length === 0) return; 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)); setValue((prev) => (prev === "" ? incoming : prev));
}, [state.title]); }, [state.title]);
+1 -3
View File
@@ -7,9 +7,7 @@ import { fetchAuthSession, logout } from "../../lib/create/api";
export default function ProfilePageClient() { export default function ProfilePageClient() {
const t = useTranslation("pages.profile"); const t = useTranslation("pages.profile");
const [user, setUser] = useState<{ id: string; email: string } | null>( const [user, setUser] = useState<{ id: string; email: string } | null>(null);
null,
);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
+12 -12
View File
@@ -173,7 +173,7 @@ Optional: **Docker image deploy** using the repo [Dockerfile](Dockerfile)—admi
**Acceptance criteria:** **Acceptance criteria:**
- [x] Completed step still works; **Save & Exit** gating uses session + step (not conflated with `completed` only). - [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). **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). **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:** **Implementation:**
1. **Hydration:** Show a non-blocking “Loading your saved progress…” until first session + draft fetch completes (only when sync enabled). 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:** 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. 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):** 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). 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):** On `ok: false`, show toast/banner (include `message`); optionally retry with backoff. 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:** Component test or Playwright scenario with sync flag on (may require test DB or route mocks). 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:** **Acceptance criteria:**
- [ ] No silent data loss when server save fails. - [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).
- [ ] User understands when server draft replaced local state (if applicable). - [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/`.
--- ---
@@ -519,11 +519,11 @@ 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. **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) | | 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 | | 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 | | 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) | | 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)| | 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) | | 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 | | 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 | | 7 | [CR-78](https://linear.app/community-rule/issue/CR-78/backend-prisma-seed-ruletemplate-document) | Template seed |
+37 -2
View File
@@ -80,16 +80,51 @@ export async function fetchDraftFromServer(): Promise<CreateFlowState | null> {
return data.draft.payload as CreateFlowState; 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( export async function saveDraftToServer(
state: CreateFlowState, state: CreateFlowState,
): Promise<boolean> { ): Promise<SaveDraftResult> {
try {
const res = await fetch("/api/drafts/me", { const res = await fetch("/api/drafts/me", {
method: "PUT", method: "PUT",
credentials: "include", credentials: "include",
headers: jsonHeaders, headers: jsonHeaders,
body: JSON.stringify({ payload: state }), body: JSON.stringify({ payload: state }),
}); });
return res.ok; 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: { 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", "saveAndExit": "Save & Exit",
"exit": "Exit", "exit": "Exit",
"leaveConfirmLoss": "Leave create flow? Your progress will be lost.", "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 metadata from "./metadata.json";
import communication from "./create/communication.json"; import communication from "./create/communication.json";
import createTopNav from "./create/topNav.json"; import createTopNav from "./create/topNav.json";
import createDraftHydration from "./create/draftHydration.json";
export default { export default {
common, common,
@@ -43,6 +44,7 @@ export default {
create: { create: {
communication, communication,
topNav: createTopNav, topNav: createTopNav,
draftHydration: createDraftHydration,
}, },
navigation, navigation,
metadata, metadata,
+4 -4
View File
@@ -48,8 +48,8 @@ function FakeMarketingPageBehindOverlay({
/> />
<div className="relative z-0 px-8 py-16"> <div className="relative z-0 px-8 py-16">
<p className="font-inter max-w-md text-lg text-neutral-800"> <p className="font-inter max-w-md text-lg text-neutral-800">
Placeholder page content the login overlay portals above this and uses Placeholder page content the login overlay portals above this and
backdrop blur (`blurredYellow`). uses backdrop blur (`blurredYellow`).
</p> </p>
</div> </div>
{children} {children}
@@ -71,7 +71,7 @@ export default {
docs: { docs: {
description: { description: {
component: 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: { docs: {
description: { description: {
story: 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 { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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 { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent, waitFor } from "@testing-library/react"; import { screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/vitest"; 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 { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react"; import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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.",
});
});
});