diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 729d37f..587556f 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -44,6 +44,7 @@ import { } from "./utils/createFlowScreenRegistry"; import Button from "../../components/buttons/Button"; import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail"; +import { buildCreateFlowDraftPayload } from "../../../lib/create/buildCreateFlowDraftPayload"; import { fetchAuthSession, requestMagicLink, @@ -52,6 +53,7 @@ import { safeInternalPath } from "../../../lib/safeInternalPath"; import { clearAnonymousCreateFlowStorage, setTransferPendingFlag, + writeAnonymousCreateFlowState, } from "./utils/anonymousDraftStorage"; import { createFlowStateFromPublishedRule, @@ -396,7 +398,15 @@ function CreateFlowLayoutContent({ const segment = stepAfterSave ?? "review"; const rawNext = `/create/${segment}?syncDraft=1`; const nextPath = safeInternalPath(rawNext); - const result = await requestMagicLink(trimmed, nextPath); + const draftPayload = buildCreateFlowDraftPayload(state, currentStep); + writeAnonymousCreateFlowState({ + ...draftPayload, + communitySaveEmail: trimmed, + }); + const result = await requestMagicLink(trimmed, nextPath, { + ...draftPayload, + communitySaveEmail: trimmed, + }); if (result.ok === false) { if (result.retryAfterMs != null && result.retryAfterMs > 0) { const seconds = Math.ceil(result.retryAfterMs / 1000); @@ -418,7 +428,7 @@ function CreateFlowLayoutContent({ } finally { setCommunitySaveMagicLinkSubmitting(false); } - }, [state.communitySaveEmail, tLogin, updateState]); + }, [state, currentStep, tLogin, updateState]); const isCompletedStep = currentStep === "completed"; const isRightRailStep = currentStep === "decision-approaches"; diff --git a/app/(app)/create/PostLoginDraftTransfer.tsx b/app/(app)/create/PostLoginDraftTransfer.tsx index 178469e..6c43f7a 100644 --- a/app/(app)/create/PostLoginDraftTransfer.tsx +++ b/app/(app)/create/PostLoginDraftTransfer.tsx @@ -9,16 +9,55 @@ import { } from "./utils/anonymousDraftStorage"; import { useCreateFlow } from "./context/CreateFlowContext"; import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps"; -import { saveDraftToServer } from "../../../lib/create/api"; +import { fetchDraftFromServer, saveDraftToServer } from "../../../lib/create/api"; +import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils"; +import type { CreateFlowState } from "./types"; import messages from "../../../messages/en/index"; import Alert from "../../components/modals/Alert"; const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; +function buildPayloadWithStep( + base: CreateFlowState, + pathname: string | null, +): CreateFlowState { + const step = + parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined; + return { + ...base, + ...(step ? { currentStep: step } : {}), + }; +} + +/** + * Prefer the on-device anonymous mirror when present; otherwise use the draft + * stored on the magic-link token at request time (written during verify). + */ +async function resolvePostLoginDraftPayload( + local: CreateFlowState, + pathname: string | null, +): Promise { + const localPayload = createFlowStateHasKeys(local) + ? buildPayloadWithStep(local, pathname) + : null; + + const serverDraft = await fetchDraftFromServer(); + const serverPayload = + serverDraft != null && createFlowStateHasKeys(serverDraft) + ? buildPayloadWithStep(serverDraft, pathname) + : null; + + if (localPayload && serverPayload) { + return { ...serverPayload, ...localPayload }; + } + return localPayload ?? serverPayload; +} + /** * After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie. - * With backend sync: PUT draft once then hydrates context. Without sync: hydrates from - * `create-flow-anonymous` localStorage only (no server write). + * With backend sync: PUT draft once when the device mirror is non-empty, then hydrates + * context. Without sync: hydrates from localStorage and/or the server draft saved at + * verify. Never writes an empty payload over an existing server draft. */ export function PostLoginDraftTransfer({ sessionUser, @@ -39,49 +78,6 @@ export function PostLoginDraftTransfer({ if (!wantsTransfer) return; if (attemptedRef.current) return; - if (!SYNC_ENABLED) { - attemptedRef.current = true; - let cancelled = false; - void (async () => { - const local = readAnonymousCreateFlowState(); - const pending = hasTransferPendingFlag(); - - if (Object.keys(local).length === 0 && !pending) { - const params = new URLSearchParams(searchParams.toString()); - params.delete("syncDraft"); - const q = params.toString(); - if (pathname) { - router.replace(q ? `${pathname}?${q}` : pathname); - } - attemptedRef.current = false; - return; - } - - const step = - parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined; - const payload = { - ...local, - ...(step ? { currentStep: step } : {}), - }; - - if (cancelled) return; - clearAnonymousCreateFlowStorage(); - replaceState(payload); - - if (cancelled) return; - if (pathname) { - const params = new URLSearchParams(searchParams.toString()); - params.delete("syncDraft"); - const q = params.toString(); - router.replace(q ? `${pathname}?${q}` : pathname); - } - })(); - - return () => { - cancelled = true; - }; - } - attemptedRef.current = true; let cancelled = false; @@ -90,7 +86,7 @@ export function PostLoginDraftTransfer({ const local = readAnonymousCreateFlowState(); const pending = hasTransferPendingFlag(); - if (Object.keys(local).length === 0 && !pending) { + if (!createFlowStateHasKeys(local) && !pending) { const params = new URLSearchParams(searchParams.toString()); params.delete("syncDraft"); const q = params.toString(); @@ -101,27 +97,36 @@ export function PostLoginDraftTransfer({ return; } - const step = - parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined; - const payload = { - ...local, - ...(step ? { currentStep: step } : {}), - }; - - const saveResult = await saveDraftToServer(payload); + const payload = await resolvePostLoginDraftPayload(local, pathname); if (cancelled) return; - if (saveResult.ok === false) { - setTransferError( - messages.create.topNav.postLoginSaveFailedWithReason.replace( - "{reason}", - saveResult.message, - ), - ); + if (payload == null || !createFlowStateHasKeys(payload)) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("syncDraft"); + const q = params.toString(); + if (pathname) { + router.replace(q ? `${pathname}?${q}` : pathname); + } attemptedRef.current = false; return; } + if (SYNC_ENABLED && createFlowStateHasKeys(local)) { + const saveResult = await saveDraftToServer(payload); + if (cancelled) return; + + if (saveResult.ok === false) { + setTransferError( + messages.create.topNav.postLoginSaveFailedWithReason.replace( + "{reason}", + saveResult.message, + ), + ); + attemptedRef.current = false; + return; + } + } + clearAnonymousCreateFlowStorage(); replaceState(payload); diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx index 69e34f0..bed4ce3 100644 --- a/app/(app)/create/SignedInDraftHydration.tsx +++ b/app/(app)/create/SignedInDraftHydration.tsx @@ -65,7 +65,6 @@ export function SignedInDraftHydration({ if (finishedUserIdRef.current === userId) return; if (syncDraftParam === "1" || hasTransferPendingFlag()) { - finishedUserIdRef.current = userId; return; } diff --git a/app/(app)/create/context/CreateFlowContext.tsx b/app/(app)/create/context/CreateFlowContext.tsx index 37169fe..d6cb111 100644 --- a/app/(app)/create/context/CreateFlowContext.tsx +++ b/app/(app)/create/context/CreateFlowContext.tsx @@ -18,6 +18,7 @@ import type { import { clearAnonymousCreateFlowStorage, clearLegacyCreateFlowKeysOnce, + hasTransferPendingFlag, readAnonymousCreateFlowState, writeAnonymousCreateFlowState, } from "../utils/anonymousDraftStorage"; @@ -94,6 +95,13 @@ export function CreateFlowProvider({ const wasOff = !prevPersistRef.current; prevPersistRef.current = true; if (!wasOff) return; + if (hasTransferPendingFlag()) return; + if ( + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get("syncDraft") === "1" + ) { + return; + } const from = readAnonymousCreateFlowState(); if (Object.keys(from).length === 0) return; // eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate local draft when mirroring turns on diff --git a/app/api/auth/magic-link/request/route.ts b/app/api/auth/magic-link/request/route.ts index c91a166..64ccc16 100644 --- a/app/api/auth/magic-link/request/route.ts +++ b/app/api/auth/magic-link/request/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import type { Prisma } from "@prisma/client"; import { prisma } from "../../../../../lib/server/db"; import { getSessionPepper, @@ -19,6 +20,8 @@ import { import { logRouteError } from "../../../../../lib/server/requestId"; import { apiRoute } from "../../../../../lib/server/apiRoute"; import { safeInternalPath } from "../../../../../lib/safeInternalPath"; +import { magicLinkRequestBodySchema } from "../../../../../lib/server/validation/createFlowSchemas"; +import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp"; const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; const EMAIL_MIN_INTERVAL_MS = 60 * 1000; @@ -32,13 +35,6 @@ function normalizeEmail(raw: unknown): string | null { return email; } -function readNextPath(body: unknown): string | null { - if (!body || typeof body !== "object" || !("next" in body)) return null; - const n = (body as { next: unknown }).next; - if (typeof n !== "string") return null; - return safeInternalPath(n); -} - export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => { if (!isDatabaseConfigured()) { return dbUnavailable(); @@ -51,15 +47,21 @@ export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { request return errorJson("invalid_json", "Invalid JSON", 400); } - const email = normalizeEmail( - body && typeof body === "object" && "email" in body - ? (body as { email: unknown }).email - : null, - ); + const parsed = magicLinkRequestBodySchema.safeParse(body); + if (!parsed.success) { + return jsonFromZodError(parsed.error); + } + + const email = normalizeEmail(parsed.data.email); if (!email) { return errorJson("validation_error", "Valid email required", 400); } + const nextPath = parsed.data.next + ? safeInternalPath(parsed.data.next) + : null; + const draftPayload = parsed.data.draft as Prisma.InputJsonValue | undefined; + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip") ?? @@ -85,7 +87,6 @@ export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { request const token = newSessionToken(); const tokenHash = hashSessionToken(token, pepper); const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS); - const nextPath = readNextPath(body); await prisma.magicLinkToken.deleteMany({ where: { email } }); await prisma.magicLinkToken.create({ @@ -94,6 +95,7 @@ export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { request tokenHash, expiresAt, nextPath: nextPath ?? undefined, + ...(draftPayload !== undefined ? { draftPayload } : {}), }, }); diff --git a/app/api/auth/magic-link/verify/route.ts b/app/api/auth/magic-link/verify/route.ts index 02f24cd..d0f6c18 100644 --- a/app/api/auth/magic-link/verify/route.ts +++ b/app/api/auth/magic-link/verify/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import type { Prisma } from "@prisma/client"; import { prisma } from "../../../../../lib/server/db"; import { getSessionPepper, @@ -68,6 +69,19 @@ export async function GET(request: NextRequest) { update: {}, }); + if (row.draftPayload != null) { + await prisma.ruleDraft.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + payload: row.draftPayload as Prisma.InputJsonValue, + }, + update: { + payload: row.draftPayload as Prisma.InputJsonValue, + }, + }); + } + const { token: sessionToken, expiresAt } = await createSessionForUser( user.id, ); diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx index e99f9b3..b399259 100644 --- a/app/components/modals/Login/LoginForm.tsx +++ b/app/components/modals/Login/LoginForm.tsx @@ -9,8 +9,12 @@ import TextInput from "../../controls/TextInput"; import ContentLockup from "../../type/ContentLockup"; import Alert from "../Alert"; import { requestMagicLink } from "../../../../lib/create/api"; +import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload"; import { safeInternalPath } from "../../../../lib/safeInternalPath"; -import { setTransferPendingFlag } from "../../../(app)/create/utils/anonymousDraftStorage"; +import { + readAnonymousCreateFlowState, + setTransferPendingFlag, +} from "../../../(app)/create/utils/anonymousDraftStorage"; /** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */ function MailIconInline() { @@ -91,7 +95,14 @@ export default function LoginForm({ try { const rawNext = magicLinkNextPath ?? nextParam; const nextPath = safeInternalPath(rawNext); - const result = await requestMagicLink(trimmed, nextPath); + const shouldAttachDraft = + isSaveProgress || nextPath.includes("syncDraft=1"); + const localDraft = readAnonymousCreateFlowState(); + const draft = + shouldAttachDraft && Object.keys(localDraft).length > 0 + ? buildCreateFlowDraftPayload(localDraft) + : undefined; + const result = await requestMagicLink(trimmed, nextPath, draft); if (result.ok === false) { if (result.retryAfterMs != null && result.retryAfterMs > 0) { const seconds = Math.ceil(result.retryAfterMs / 1000); diff --git a/lib/create/api.ts b/lib/create/api.ts index cd8bdde..bab7364 100644 --- a/lib/create/api.ts +++ b/lib/create/api.ts @@ -41,6 +41,7 @@ export async function fetchAuthSession(): Promise<{ export async function requestMagicLink( email: string, nextPath?: string, + draft?: CreateFlowState, ): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> { const res = await fetch("/api/auth/magic-link/request", { method: "POST", @@ -49,6 +50,7 @@ export async function requestMagicLink( body: JSON.stringify({ email, ...(nextPath ? { next: nextPath } : {}), + ...(draft && Object.keys(draft).length > 0 ? { draft } : {}), }), }); const data = await parseJson<{ error?: string; retryAfterMs?: number }>(res); diff --git a/lib/create/buildCreateFlowDraftPayload.ts b/lib/create/buildCreateFlowDraftPayload.ts new file mode 100644 index 0000000..36c0327 --- /dev/null +++ b/lib/create/buildCreateFlowDraftPayload.ts @@ -0,0 +1,12 @@ +import type { CreateFlowState, CreateFlowStep } from "../../app/(app)/create/types"; + +/** Snapshot for save-progress / draft transfer (includes optional resume step). */ +export function buildCreateFlowDraftPayload( + state: CreateFlowState, + currentStep?: CreateFlowStep | null, +): CreateFlowState { + return { + ...state, + ...(currentStep ? { currentStep } : {}), + }; +} diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index 41adccd..bf3a4d2 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -222,3 +222,10 @@ export const putDraftBodySchema = z.object({ payload: createFlowStateSchema, }); export type CreateFlowStateValidated = z.infer; + +export const magicLinkRequestBodySchema = z.object({ + email: z.string(), + next: z.string().optional(), + draft: createFlowStateSchema.optional(), +}); +export type MagicLinkRequestBody = z.infer; diff --git a/prisma/migrations/20260520120000_add_magic_link_draft_payload/migration.sql b/prisma/migrations/20260520120000_add_magic_link_draft_payload/migration.sql new file mode 100644 index 0000000..db92989 --- /dev/null +++ b/prisma/migrations/20260520120000_add_magic_link_draft_payload/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "MagicLinkToken" ADD COLUMN "draftPayload" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a24a997..d3fbf29 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,12 +50,14 @@ model Session { } model MagicLinkToken { - id String @id @default(cuid()) - email String - tokenHash String @unique - expiresAt DateTime - nextPath String? - createdAt DateTime @default(now()) + id String @id @default(cuid()) + email String + tokenHash String @unique + expiresAt DateTime + nextPath String? + /// Optional create-flow draft captured at magic-link request (save-progress). + draftPayload Json? + createdAt DateTime @default(now()) @@index([email]) } diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx index 4ae6ab9..fcdd154 100644 --- a/tests/components/LoginForm.test.tsx +++ b/tests/components/LoginForm.test.tsx @@ -104,7 +104,11 @@ describe("LoginForm", () => { screen.getByRole("button", { name: /send me a magic link/i }), ); await waitFor(() => { - expect(requestMagicLink).toHaveBeenCalledWith("pat@example.com", "/"); + expect(requestMagicLink).toHaveBeenCalledWith( + "pat@example.com", + "/", + undefined, + ); }); expect( await screen.findByRole("heading", { name: /check your email/i }), @@ -134,6 +138,7 @@ describe("LoginForm", () => { expect(requestMagicLink).toHaveBeenCalledWith( "save@example.com", "/create/community-structure?syncDraft=1", + undefined, ); }); expect(setTransferPendingFlag).toHaveBeenCalled(); @@ -152,7 +157,11 @@ describe("LoginForm", () => { screen.getByRole("button", { name: /send me a magic link/i }), ); await waitFor(() => { - expect(requestMagicLink).toHaveBeenCalledWith("a@b.co", "/learn"); + expect(requestMagicLink).toHaveBeenCalledWith( + "a@b.co", + "/learn", + undefined, + ); }); }); diff --git a/tests/unit/buildCreateFlowDraftPayload.test.ts b/tests/unit/buildCreateFlowDraftPayload.test.ts new file mode 100644 index 0000000..cfeb167 --- /dev/null +++ b/tests/unit/buildCreateFlowDraftPayload.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { buildCreateFlowDraftPayload } from "../../lib/create/buildCreateFlowDraftPayload"; + +describe("buildCreateFlowDraftPayload", () => { + it("merges state with currentStep when provided", () => { + expect( + buildCreateFlowDraftPayload( + { title: "Oak Street Collective" }, + "community-save", + ), + ).toEqual({ + title: "Oak Street Collective", + currentStep: "community-save", + }); + }); + + it("returns state unchanged when currentStep is omitted", () => { + expect( + buildCreateFlowDraftPayload({ title: "Oak Street Collective" }), + ).toEqual({ title: "Oak Street Collective" }); + }); +});