Fix save progress bug
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user