Fix save progress bug

This commit is contained in:
adilallo
2026-05-20 19:58:32 -06:00
parent 2f2b5d0dc2
commit 7ee6282c1a
14 changed files with 193 additions and 88 deletions
+12 -2
View File
@@ -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";
+67 -62
View File
@@ -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<CreateFlowState | null> {
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);
@@ -65,7 +65,6 @@ export function SignedInDraftHydration({
if (finishedUserIdRef.current === userId) return;
if (syncDraftParam === "1" || hasTransferPendingFlag()) {
finishedUserIdRef.current = userId;
return;
}
@@ -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
+15 -13
View File
@@ -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 } : {}),
},
});
+14
View File
@@ -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,
);
+13 -2
View File
@@ -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);
+2
View File
@@ -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);
+12
View File
@@ -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 } : {}),
};
}
@@ -222,3 +222,10 @@ export const putDraftBodySchema = z.object({
payload: createFlowStateSchema,
});
export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>;
export const magicLinkRequestBodySchema = z.object({
email: z.string(),
next: z.string().optional(),
draft: createFlowStateSchema.optional(),
});
export type MagicLinkRequestBody = z.infer<typeof magicLinkRequestBodySchema>;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "MagicLinkToken" ADD COLUMN "draftPayload" JSONB;
+8 -6
View File
@@ -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])
}
+11 -2
View File
@@ -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,
);
});
});
@@ -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" });
});
});