Fix save progress bug
This commit is contained in:
@@ -44,6 +44,7 @@ import {
|
|||||||
} from "./utils/createFlowScreenRegistry";
|
} from "./utils/createFlowScreenRegistry";
|
||||||
import Button from "../../components/buttons/Button";
|
import Button from "../../components/buttons/Button";
|
||||||
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
|
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
|
import { buildCreateFlowDraftPayload } from "../../../lib/create/buildCreateFlowDraftPayload";
|
||||||
import {
|
import {
|
||||||
fetchAuthSession,
|
fetchAuthSession,
|
||||||
requestMagicLink,
|
requestMagicLink,
|
||||||
@@ -52,6 +53,7 @@ import { safeInternalPath } from "../../../lib/safeInternalPath";
|
|||||||
import {
|
import {
|
||||||
clearAnonymousCreateFlowStorage,
|
clearAnonymousCreateFlowStorage,
|
||||||
setTransferPendingFlag,
|
setTransferPendingFlag,
|
||||||
|
writeAnonymousCreateFlowState,
|
||||||
} from "./utils/anonymousDraftStorage";
|
} from "./utils/anonymousDraftStorage";
|
||||||
import {
|
import {
|
||||||
createFlowStateFromPublishedRule,
|
createFlowStateFromPublishedRule,
|
||||||
@@ -396,7 +398,15 @@ function CreateFlowLayoutContent({
|
|||||||
const segment = stepAfterSave ?? "review";
|
const segment = stepAfterSave ?? "review";
|
||||||
const rawNext = `/create/${segment}?syncDraft=1`;
|
const rawNext = `/create/${segment}?syncDraft=1`;
|
||||||
const nextPath = safeInternalPath(rawNext);
|
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.ok === false) {
|
||||||
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
|
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
|
||||||
const seconds = Math.ceil(result.retryAfterMs / 1000);
|
const seconds = Math.ceil(result.retryAfterMs / 1000);
|
||||||
@@ -418,7 +428,7 @@ function CreateFlowLayoutContent({
|
|||||||
} finally {
|
} finally {
|
||||||
setCommunitySaveMagicLinkSubmitting(false);
|
setCommunitySaveMagicLinkSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [state.communitySaveEmail, tLogin, updateState]);
|
}, [state, currentStep, tLogin, updateState]);
|
||||||
|
|
||||||
const isCompletedStep = currentStep === "completed";
|
const isCompletedStep = currentStep === "completed";
|
||||||
const isRightRailStep = currentStep === "decision-approaches";
|
const isRightRailStep = currentStep === "decision-approaches";
|
||||||
|
|||||||
@@ -9,16 +9,55 @@ import {
|
|||||||
} from "./utils/anonymousDraftStorage";
|
} from "./utils/anonymousDraftStorage";
|
||||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
|
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 messages from "../../../messages/en/index";
|
||||||
import Alert from "../../components/modals/Alert";
|
import Alert from "../../components/modals/Alert";
|
||||||
|
|
||||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
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.
|
* 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
|
* With backend sync: PUT draft once when the device mirror is non-empty, then hydrates
|
||||||
* `create-flow-anonymous` localStorage only (no server write).
|
* 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({
|
export function PostLoginDraftTransfer({
|
||||||
sessionUser,
|
sessionUser,
|
||||||
@@ -39,14 +78,15 @@ export function PostLoginDraftTransfer({
|
|||||||
if (!wantsTransfer) return;
|
if (!wantsTransfer) return;
|
||||||
if (attemptedRef.current) return;
|
if (attemptedRef.current) return;
|
||||||
|
|
||||||
if (!SYNC_ENABLED) {
|
|
||||||
attemptedRef.current = true;
|
attemptedRef.current = true;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const local = readAnonymousCreateFlowState();
|
const local = readAnonymousCreateFlowState();
|
||||||
const pending = hasTransferPendingFlag();
|
const pending = hasTransferPendingFlag();
|
||||||
|
|
||||||
if (Object.keys(local).length === 0 && !pending) {
|
if (!createFlowStateHasKeys(local) && !pending) {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.delete("syncDraft");
|
params.delete("syncDraft");
|
||||||
const q = params.toString();
|
const q = params.toString();
|
||||||
@@ -57,40 +97,10 @@ export function PostLoginDraftTransfer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const step =
|
const payload = await resolvePostLoginDraftPayload(local, pathname);
|
||||||
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
|
|
||||||
const payload = {
|
|
||||||
...local,
|
|
||||||
...(step ? { currentStep: step } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
clearAnonymousCreateFlowStorage();
|
|
||||||
replaceState(payload);
|
|
||||||
|
|
||||||
if (cancelled) return;
|
if (payload == null || !createFlowStateHasKeys(payload)) {
|
||||||
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;
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
const local = readAnonymousCreateFlowState();
|
|
||||||
const pending = hasTransferPendingFlag();
|
|
||||||
|
|
||||||
if (Object.keys(local).length === 0 && !pending) {
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.delete("syncDraft");
|
params.delete("syncDraft");
|
||||||
const q = params.toString();
|
const q = params.toString();
|
||||||
@@ -101,13 +111,7 @@ export function PostLoginDraftTransfer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const step =
|
if (SYNC_ENABLED && createFlowStateHasKeys(local)) {
|
||||||
parseCreateFlowScreenFromPathname(pathname ?? null) ?? undefined;
|
|
||||||
const payload = {
|
|
||||||
...local,
|
|
||||||
...(step ? { currentStep: step } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveResult = await saveDraftToServer(payload);
|
const saveResult = await saveDraftToServer(payload);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
@@ -121,6 +125,7 @@ export function PostLoginDraftTransfer({
|
|||||||
attemptedRef.current = false;
|
attemptedRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clearAnonymousCreateFlowStorage();
|
clearAnonymousCreateFlowStorage();
|
||||||
replaceState(payload);
|
replaceState(payload);
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export function SignedInDraftHydration({
|
|||||||
if (finishedUserIdRef.current === userId) return;
|
if (finishedUserIdRef.current === userId) return;
|
||||||
|
|
||||||
if (syncDraftParam === "1" || hasTransferPendingFlag()) {
|
if (syncDraftParam === "1" || hasTransferPendingFlag()) {
|
||||||
finishedUserIdRef.current = userId;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
clearAnonymousCreateFlowStorage,
|
clearAnonymousCreateFlowStorage,
|
||||||
clearLegacyCreateFlowKeysOnce,
|
clearLegacyCreateFlowKeysOnce,
|
||||||
|
hasTransferPendingFlag,
|
||||||
readAnonymousCreateFlowState,
|
readAnonymousCreateFlowState,
|
||||||
writeAnonymousCreateFlowState,
|
writeAnonymousCreateFlowState,
|
||||||
} from "../utils/anonymousDraftStorage";
|
} from "../utils/anonymousDraftStorage";
|
||||||
@@ -94,6 +95,13 @@ export function CreateFlowProvider({
|
|||||||
const wasOff = !prevPersistRef.current;
|
const wasOff = !prevPersistRef.current;
|
||||||
prevPersistRef.current = true;
|
prevPersistRef.current = true;
|
||||||
if (!wasOff) return;
|
if (!wasOff) return;
|
||||||
|
if (hasTransferPendingFlag()) return;
|
||||||
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
new URLSearchParams(window.location.search).get("syncDraft") === "1"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const from = readAnonymousCreateFlowState();
|
const from = readAnonymousCreateFlowState();
|
||||||
if (Object.keys(from).length === 0) return;
|
if (Object.keys(from).length === 0) return;
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate local draft when mirroring turns on
|
// 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 { NextRequest, NextResponse } from "next/server";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "../../../../../lib/server/db";
|
import { prisma } from "../../../../../lib/server/db";
|
||||||
import {
|
import {
|
||||||
getSessionPepper,
|
getSessionPepper,
|
||||||
@@ -19,6 +20,8 @@ import {
|
|||||||
import { logRouteError } from "../../../../../lib/server/requestId";
|
import { logRouteError } from "../../../../../lib/server/requestId";
|
||||||
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
||||||
import { safeInternalPath } from "../../../../../lib/safeInternalPath";
|
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 MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
|
||||||
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
|
||||||
@@ -32,13 +35,6 @@ function normalizeEmail(raw: unknown): string | null {
|
|||||||
return email;
|
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 }) => {
|
export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
|
||||||
if (!isDatabaseConfigured()) {
|
if (!isDatabaseConfigured()) {
|
||||||
return dbUnavailable();
|
return dbUnavailable();
|
||||||
@@ -51,15 +47,21 @@ export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { request
|
|||||||
return errorJson("invalid_json", "Invalid JSON", 400);
|
return errorJson("invalid_json", "Invalid JSON", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = normalizeEmail(
|
const parsed = magicLinkRequestBodySchema.safeParse(body);
|
||||||
body && typeof body === "object" && "email" in body
|
if (!parsed.success) {
|
||||||
? (body as { email: unknown }).email
|
return jsonFromZodError(parsed.error);
|
||||||
: null,
|
}
|
||||||
);
|
|
||||||
|
const email = normalizeEmail(parsed.data.email);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return errorJson("validation_error", "Valid email required", 400);
|
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 =
|
const ip =
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
request.headers.get("x-real-ip") ??
|
request.headers.get("x-real-ip") ??
|
||||||
@@ -85,7 +87,6 @@ export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { request
|
|||||||
const token = newSessionToken();
|
const token = newSessionToken();
|
||||||
const tokenHash = hashSessionToken(token, pepper);
|
const tokenHash = hashSessionToken(token, pepper);
|
||||||
const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS);
|
const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS);
|
||||||
const nextPath = readNextPath(body);
|
|
||||||
|
|
||||||
await prisma.magicLinkToken.deleteMany({ where: { email } });
|
await prisma.magicLinkToken.deleteMany({ where: { email } });
|
||||||
await prisma.magicLinkToken.create({
|
await prisma.magicLinkToken.create({
|
||||||
@@ -94,6 +95,7 @@ export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { request
|
|||||||
tokenHash,
|
tokenHash,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
nextPath: nextPath ?? undefined,
|
nextPath: nextPath ?? undefined,
|
||||||
|
...(draftPayload !== undefined ? { draftPayload } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "../../../../../lib/server/db";
|
import { prisma } from "../../../../../lib/server/db";
|
||||||
import {
|
import {
|
||||||
getSessionPepper,
|
getSessionPepper,
|
||||||
@@ -68,6 +69,19 @@ export async function GET(request: NextRequest) {
|
|||||||
update: {},
|
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(
|
const { token: sessionToken, expiresAt } = await createSessionForUser(
|
||||||
user.id,
|
user.id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ import TextInput from "../../controls/TextInput";
|
|||||||
import ContentLockup from "../../type/ContentLockup";
|
import ContentLockup from "../../type/ContentLockup";
|
||||||
import Alert from "../Alert";
|
import Alert from "../Alert";
|
||||||
import { requestMagicLink } from "../../../../lib/create/api";
|
import { requestMagicLink } from "../../../../lib/create/api";
|
||||||
|
import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload";
|
||||||
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
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). */
|
/** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */
|
||||||
function MailIconInline() {
|
function MailIconInline() {
|
||||||
@@ -91,7 +95,14 @@ export default function LoginForm({
|
|||||||
try {
|
try {
|
||||||
const rawNext = magicLinkNextPath ?? nextParam;
|
const rawNext = magicLinkNextPath ?? nextParam;
|
||||||
const nextPath = safeInternalPath(rawNext);
|
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.ok === false) {
|
||||||
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
|
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
|
||||||
const seconds = Math.ceil(result.retryAfterMs / 1000);
|
const seconds = Math.ceil(result.retryAfterMs / 1000);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export async function fetchAuthSession(): Promise<{
|
|||||||
export async function requestMagicLink(
|
export async function requestMagicLink(
|
||||||
email: string,
|
email: string,
|
||||||
nextPath?: string,
|
nextPath?: string,
|
||||||
|
draft?: CreateFlowState,
|
||||||
): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> {
|
): Promise<{ ok: true } | { ok: false; error: string; retryAfterMs?: number }> {
|
||||||
const res = await fetch("/api/auth/magic-link/request", {
|
const res = await fetch("/api/auth/magic-link/request", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -49,6 +50,7 @@ export async function requestMagicLink(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email,
|
email,
|
||||||
...(nextPath ? { next: nextPath } : {}),
|
...(nextPath ? { next: nextPath } : {}),
|
||||||
|
...(draft && Object.keys(draft).length > 0 ? { draft } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await parseJson<{ error?: string; retryAfterMs?: number }>(res);
|
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,
|
payload: createFlowStateSchema,
|
||||||
});
|
});
|
||||||
export type CreateFlowStateValidated = z.infer<typeof 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;
|
||||||
@@ -55,6 +55,8 @@ model MagicLinkToken {
|
|||||||
tokenHash String @unique
|
tokenHash String @unique
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
nextPath String?
|
nextPath String?
|
||||||
|
/// Optional create-flow draft captured at magic-link request (save-progress).
|
||||||
|
draftPayload Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ describe("LoginForm", () => {
|
|||||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(requestMagicLink).toHaveBeenCalledWith("pat@example.com", "/");
|
expect(requestMagicLink).toHaveBeenCalledWith(
|
||||||
|
"pat@example.com",
|
||||||
|
"/",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: /check your email/i }),
|
await screen.findByRole("heading", { name: /check your email/i }),
|
||||||
@@ -134,6 +138,7 @@ describe("LoginForm", () => {
|
|||||||
expect(requestMagicLink).toHaveBeenCalledWith(
|
expect(requestMagicLink).toHaveBeenCalledWith(
|
||||||
"save@example.com",
|
"save@example.com",
|
||||||
"/create/community-structure?syncDraft=1",
|
"/create/community-structure?syncDraft=1",
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(setTransferPendingFlag).toHaveBeenCalled();
|
expect(setTransferPendingFlag).toHaveBeenCalled();
|
||||||
@@ -152,7 +157,11 @@ describe("LoginForm", () => {
|
|||||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||||
);
|
);
|
||||||
await waitFor(() => {
|
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