diff --git a/app/components/modals/Login/Login.view.tsx b/app/components/modals/Login/Login.view.tsx index dc2d196..934eec7 100644 --- a/app/components/modals/Login/Login.view.tsx +++ b/app/components/modals/Login/Login.view.tsx @@ -5,8 +5,7 @@ import ModalHeader from "../../utility/ModalHeader"; import type { LoginBackdropVariant, LoginViewProps } from "./Login.types"; const backdropClasses: Record = { - 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", }; diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx index c6e6920..8d0eb5b 100644 --- a/app/components/modals/Login/LoginForm.tsx +++ b/app/components/modals/Login/LoginForm.tsx @@ -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" diff --git a/app/create/PostLoginDraftTransfer.tsx b/app/create/PostLoginDraftTransfer.tsx index bdf0dbd..e140715 100644 --- a/app/create/PostLoginDraftTransfer.tsx +++ b/app/create/PostLoginDraftTransfer.tsx @@ -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; diff --git a/app/create/SignedInDraftHydration.tsx b/app/create/SignedInDraftHydration.tsx new file mode 100644 index 0000000..229fb30 --- /dev/null +++ b/app/create/SignedInDraftHydration.tsx @@ -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(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 ( +
+ {messages.create.draftHydration.loadingSavedProgress} +
+ ); +} diff --git a/app/create/anonymousDraftStorage.ts b/app/create/anonymousDraftStorage.ts index 01cb4e9..9026bbb 100644 --- a/app/create/anonymousDraftStorage.ts +++ b/app/create/anonymousDraftStorage.ts @@ -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"; diff --git a/app/create/context/CreateFlowContext.tsx b/app/create/context/CreateFlowContext.tsx index 1399ebb..0a0c5ce 100644 --- a/app/create/context/CreateFlowContext.tsx +++ b/app/create/context/CreateFlowContext.tsx @@ -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(() => { diff --git a/app/create/context/CreateFlowDraftSaveBannerContext.tsx b/app/create/context/CreateFlowDraftSaveBannerContext.tsx new file mode 100644 index 0000000..7d41ad6 --- /dev/null +++ b/app/create/context/CreateFlowDraftSaveBannerContext.tsx @@ -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(null); + +export function CreateFlowDraftSaveBannerProvider({ + children, +}: { + children: ReactNode; +}) { + const [draftSaveBannerMessage, setDraftSaveBannerMessage] = useState< + string | null + >(null); + + const value = useMemo( + () => ({ + draftSaveBannerMessage, + setDraftSaveBannerMessage, + }), + [draftSaveBannerMessage], + ); + + return ( + + {children} + + ); +} + +export function useCreateFlowDraftSaveBanner(): CreateFlowDraftSaveBannerContextValue { + const ctx = useContext(CreateFlowDraftSaveBannerContext); + if (!ctx) { + throw new Error( + "useCreateFlowDraftSaveBanner must be used within CreateFlowDraftSaveBannerProvider", + ); + } + return ctx; +} diff --git a/app/create/hooks/useCreateFlowExit.ts b/app/create/hooks/useCreateFlowExit.ts index 34d78c3..d7b5315 100644 --- a/app/create/hooks/useCreateFlowExit.ts +++ b/app/create/hooks/useCreateFlowExit.ts @@ -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 { + /** When save fails, surface the server message in the create shell banner (no leave confirm). */ + setDraftSaveBannerMessage?: (_message: string | null) => void; +}): (_options?: { saveDraft?: boolean }) => Promise { 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], ); } diff --git a/app/create/layout.tsx b/app/create/layout.tsx index 113f987..7069cb2 100644 --- a/app/create/layout.tsx +++ b/app/create/layout.tsx @@ -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 ( - - - {children} - + + + + {children} + + ); } @@ -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({
+ {draftSaveBannerMessage ? ( +
+ setDraftSaveBannerMessage(null)} + className="w-full max-w-[960px] mx-auto" + /> +
+ ) : null} + + + diff --git a/app/create/text/page.tsx b/app/create/text/page.tsx index 89e743a..945a1d1 100644 --- a/app/create/text/page.tsx +++ b/app/create/text/page.tsx @@ -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]); diff --git a/app/profile/ProfilePageClient.tsx b/app/profile/ProfilePageClient.tsx index ed3363f..f2a6f4f 100644 --- a/app/profile/ProfilePageClient.tsx +++ b/app/profile/ProfilePageClient.tsx @@ -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(() => { diff --git a/docs/backend-linear-tickets.md b/docs/backend-linear-tickets.md index 2961831..d322773 100644 --- a/docs/backend-linear-tickets.md +++ b/docs/backend-linear-tickets.md @@ -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` 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 **10–11** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 16** is also **deferrable** until after **7–8** (flat template list + UI); it adds **spreadsheet-driven** recommendations and facet APIs. **Tickets 13–14** 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 **10–11** 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-72–83 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 | --- diff --git a/docs/backend-roadmap.md b/docs/backend-roadmap.md index 7558095..b49afba 100644 --- a/docs/backend-roadmap.md +++ b/docs/backend-roadmap.md @@ -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 7–8 (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. diff --git a/lib/create/api.ts b/lib/create/api.ts index a73159b..f674ae0 100644 --- a/lib/create/api.ts +++ b/lib/create/api.ts @@ -80,16 +80,51 @@ export async function fetchDraftFromServer(): Promise { 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 { + 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 { - const res = await fetch("/api/drafts/me", { - method: "PUT", - credentials: "include", - headers: jsonHeaders, - body: JSON.stringify({ payload: state }), - }); - return res.ok; +): Promise { + 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: { diff --git a/lib/create/draftHydrationUtils.ts b/lib/create/draftHydrationUtils.ts new file mode 100644 index 0000000..6842881 --- /dev/null +++ b/lib/create/draftHydrationUtils.ts @@ -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; +} diff --git a/messages/en/create/draftHydration.json b/messages/en/create/draftHydration.json new file mode 100644 index 0000000..d92cdbc --- /dev/null +++ b/messages/en/create/draftHydration.json @@ -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." +} diff --git a/messages/en/create/topNav.json b/messages/en/create/topNav.json index 612a6a7..16f61c5 100644 --- a/messages/en/create/topNav.json +++ b/messages/en/create/topNav.json @@ -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}" } diff --git a/messages/en/index.ts b/messages/en/index.ts index f8a919f..367413d 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -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, diff --git a/stories/modals/Login.stories.tsx b/stories/modals/Login.stories.tsx index 7834728..b449be4 100644 --- a/stories/modals/Login.stories.tsx +++ b/stories/modals/Login.stories.tsx @@ -48,8 +48,8 @@ function FakeMarketingPageBehindOverlay({ />

- 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`).

{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.', }, }, }, diff --git a/tests/components/AuthModalContext.test.tsx b/tests/components/AuthModalContext.test.tsx index efae990..73bb798 100644 --- a/tests/components/AuthModalContext.test.tsx +++ b/tests/components/AuthModalContext.test.tsx @@ -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"; diff --git a/tests/components/Login.test.tsx b/tests/components/Login.test.tsx index 94ae628..c8608b7 100644 --- a/tests/components/Login.test.tsx +++ b/tests/components/Login.test.tsx @@ -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"; diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx index 1ec5139..759c12e 100644 --- a/tests/components/LoginForm.test.tsx +++ b/tests/components/LoginForm.test.tsx @@ -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"; diff --git a/tests/unit/draftHydrationUtils.test.ts b/tests/unit/draftHydrationUtils.test.ts new file mode 100644 index 0000000..de45a83 --- /dev/null +++ b/tests/unit/draftHydrationUtils.test.ts @@ -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); + }); +}); diff --git a/tests/unit/saveDraftToServer.test.ts b/tests/unit/saveDraftToServer.test.ts new file mode 100644 index 0000000..bf4af65 --- /dev/null +++ b/tests/unit/saveDraftToServer.test.ts @@ -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.", + }); + }); +});