Walk Create Community through the current screens and restore a chosen logo after reload #96

Merged
an.di merged 2 commits from adilallo/fix/CR-279-create-community into main 2026-11-06 03:04:49 +00:00
19 changed files with 780 additions and 61 deletions
Showing only changes of commit 8212423ec3 - Show all commits

No files matched your search

+26 -3
View File
@@ -16,6 +16,7 @@ import messages from "../../../messages/en/index";
import Alert from "../../components/modals/Alert"; import Alert from "../../components/modals/Alert";
import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled"; import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled";
import { flushPendingCommunityAvatar } from "../../../lib/create/flushPendingCommunityAvatar";
function buildPayloadWithStep( function buildPayloadWithStep(
base: CreateFlowState, base: CreateFlowState,
@@ -111,8 +112,15 @@ export function PostLoginDraftTransfer({
return; return;
} }
const flushed = await flushPendingCommunityAvatar();
if (cancelled) return;
const transferred =
flushed.status === "uploaded"
? { ...payload, communityAvatarUrl: flushed.url }
: payload;
if (isBackendSyncEnabled() && createFlowStateHasKeys(local)) { if (isBackendSyncEnabled() && createFlowStateHasKeys(local)) {
const saveResult = await saveDraftToServer(payload); const saveResult = await saveDraftToServer(transferred);
if (cancelled) return; if (cancelled) return;
if (saveResult.ok === false) { if (saveResult.ok === false) {
@@ -127,8 +135,23 @@ export function PostLoginDraftTransfer({
} }
} }
clearAnonymousCreateFlowStorage(); clearAnonymousCreateFlowStorage({
replaceState(payload); preservePendingAvatar: flushed.status === "failed",
});
replaceState((prev) => {
const fromPayload =
typeof transferred.communityAvatarUrl === "string"
? transferred.communityAvatarUrl.trim()
: "";
const fromPrev =
typeof prev.communityAvatarUrl === "string"
? prev.communityAvatarUrl.trim()
: "";
const communityAvatarUrl = fromPayload || fromPrev;
return communityAvatarUrl
? { ...transferred, communityAvatarUrl }
: transferred;
});
if (pathname) { if (pathname) {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
@@ -1,19 +1,16 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { useCreateFlow } from "../context/CreateFlowContext"; import { useCreateFlow } from "../context/CreateFlowContext";
import { import { flushPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar";
CreateFlowUploadValidationError,
uploadCreateFlowFile, /** Background tries after a transient upload failure. The file stays staged. */
} from "../../../../lib/create/uploadToServer"; const MAX_FLUSH_ATTEMPTS = 4;
import {
clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
} from "../../../../lib/create/pendingCommunityAvatarUpload";
/** /**
* After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick) * After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick)
* and writes `communityAvatarUrl` on success. * and writes `communityAvatarUrl` on success. A transient failure retries with
* backoff while this layout stays mounted.
*/ */
export function CreateFlowPendingAvatarFlush({ export function CreateFlowPendingAvatarFlush({
sessionUser, sessionUser,
@@ -23,7 +20,8 @@ export function CreateFlowPendingAvatarFlush({
sessionResolved: boolean; sessionResolved: boolean;
}) { }) {
const { state, updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
/** One successful flush per signed-in user id (survives React StrictMode remounts). */ const [attempt, setAttempt] = useState(0);
/** One successful flush per signed-in user id (survives the effect re-running). */
const lastFlushedUserIdRef = useRef<string | null>(null); const lastFlushedUserIdRef = useRef<string | null>(null);
const hasServerAvatar = const hasServerAvatar =
typeof state.communityAvatarUrl === "string" && typeof state.communityAvatarUrl === "string" &&
@@ -33,29 +31,31 @@ export function CreateFlowPendingAvatarFlush({
if (!sessionResolved || !sessionUser) return; if (!sessionResolved || !sessionUser) return;
if (lastFlushedUserIdRef.current === sessionUser.id) return; if (lastFlushedUserIdRef.current === sessionUser.id) return;
if (hasServerAvatar) return; if (hasServerAvatar) return;
if (attempt >= MAX_FLUSH_ATTEMPTS) return;
let cancelled = false; let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
void (async () => { void (async () => {
const file = await readPendingCommunityAvatarFile(); const result = await flushPendingCommunityAvatar();
if (cancelled || !file) return; if (cancelled) return;
try { if (result.status === "uploaded") {
const { url } = await uploadCreateFlowFile(file, "communityAvatar"); updateState({ communityAvatarUrl: result.url });
if (cancelled) return;
await clearPendingCommunityAvatarFile();
updateState({ communityAvatarUrl: url });
lastFlushedUserIdRef.current = sessionUser.id; lastFlushedUserIdRef.current = sessionUser.id;
} catch (err) { return;
if (err instanceof CreateFlowUploadValidationError) {
await clearPendingCommunityAvatarFile();
}
// Leave a transient (auth / UPLOAD_ROOT) failure in place to retry.
} }
if (result.status !== "failed") return;
if (attempt + 1 >= MAX_FLUSH_ATTEMPTS) return;
const delay = Math.min(20_000, 5_000 * 2 ** attempt);
timer = setTimeout(() => {
if (!cancelled) setAttempt((current) => current + 1);
}, delay);
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
if (timer) clearTimeout(timer);
}; };
}, [hasServerAvatar, sessionResolved, sessionUser, updateState]); }, [attempt, hasServerAvatar, sessionResolved, sessionUser, updateState]);
return null; return null;
} }
@@ -0,0 +1,65 @@
"use client";
import { useEffect, useState } from "react";
import { readPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload";
function trimmedAvatarUrl(url: string | null): string | null {
if (typeof url !== "string") return null;
const trimmed = url.trim();
return trimmed.length > 0 ? trimmed : null;
}
/**
* Logo src for review cards. Uses the uploaded URL when the draft has one,
* otherwise the guest file staged in IndexedDB.
*
* @param serverUrl `communityAvatarUrl` from create-flow state, if any.
* @returns A URL the Rule card can render, or `null` when there is no logo.
*
* @example
* const logoUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl ?? null);
*/
export function useCommunityAvatarDisplayUrl(
serverUrl: string | null | undefined,
): string | null {
const resolvedServerUrl = trimmedAvatarUrl(serverUrl ?? null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
useEffect(() => {
if (resolvedServerUrl) {
setObjectUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
return;
}
let cancelled = false;
let created: string | null = null;
void (async () => {
try {
const file = await readPendingCommunityAvatarFile();
if (cancelled || !file) return;
created = URL.createObjectURL(file);
if (cancelled) {
URL.revokeObjectURL(created);
created = null;
return;
}
setObjectUrl((prev) => {
if (prev && prev !== created) URL.revokeObjectURL(prev);
return created;
});
} catch {
// IndexedDB missing or blocked: the card keeps the placeholder.
}
})();
return () => {
cancelled = true;
if (created) URL.revokeObjectURL(created);
};
}, [resolvedServerUrl]);
return resolvedServerUrl ?? objectUrl;
}
+11 -4
View File
@@ -3,6 +3,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import type { CreateFlowState, CreateFlowStep } from "../types"; import type { CreateFlowState, CreateFlowStep } from "../types";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload"; import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { stateWithPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar";
import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api"; import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled"; import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled";
@@ -47,12 +48,18 @@ export function useCreateFlowExit({
} }
if (saveDraft && isBackendSyncEnabled()) { if (saveDraft && isBackendSyncEnabled()) {
const prepared = await stateWithPendingCommunityAvatar(state);
if (prepared.ok === false) {
setDraftSaveBannerMessage?.(messages.create.upload.errors.generic);
return;
}
const draftState = prepared.state;
const editingId = const editingId =
typeof state.editingPublishedRuleId === "string" typeof draftState.editingPublishedRuleId === "string"
? state.editingPublishedRuleId.trim() ? draftState.editingPublishedRuleId.trim()
: ""; : "";
if (editingId.length > 0) { if (editingId.length > 0) {
const payloadResult = buildPublishPayload(state); const payloadResult = buildPublishPayload(draftState);
if (payloadResult.ok === false) { if (payloadResult.ok === false) {
setDraftSaveBannerMessage?.( setDraftSaveBannerMessage?.(
payloadResult.error === "missingCommunityName" payloadResult.error === "missingCommunityName"
@@ -82,7 +89,7 @@ export function useCreateFlowExit({
} }
} else { } else {
const payload: CreateFlowState = { const payload: CreateFlowState = {
...state, ...draftState,
...(currentStep ? { currentStep } : {}), ...(currentStep ? { currentStep } : {}),
}; };
const result = await saveDraftToServer(payload); const result = await saveDraftToServer(payload);
@@ -2,6 +2,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload"; import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload";
import { stateWithPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar";
import { publishRule, updatePublishedRule } from "../../../../lib/create/api"; import { publishRule, updatePublishedRule } from "../../../../lib/create/api";
import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin"; import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin";
@@ -63,8 +64,19 @@ export function useCreateFlowFinalize({
if (sessionUser === undefined) return; if (sessionUser === undefined) return;
setPublishBannerMessage(null); setPublishBannerMessage(null);
const payloadResult = buildPublishPayload(state); setIsPublishing(true);
const prepared = await stateWithPendingCommunityAvatar(state);
if (prepared.ok === false) {
setIsPublishing(false);
setPublishBannerMessage(messages.create.upload.errors.generic);
return;
}
if (prepared.state.communityAvatarUrl !== state.communityAvatarUrl) {
updateState({ communityAvatarUrl: prepared.state.communityAvatarUrl });
}
const payloadResult = buildPublishPayload(prepared.state);
if (payloadResult.ok === false) { if (payloadResult.ok === false) {
setIsPublishing(false);
setPublishBannerMessage( setPublishBannerMessage(
payloadResult.error === "missingCommunityName" payloadResult.error === "missingCommunityName"
? messages.create.reviewAndComplete.publish.missingCommunityName ? messages.create.reviewAndComplete.publish.missingCommunityName
@@ -73,7 +85,6 @@ export function useCreateFlowFinalize({
return; return;
} }
const { title, summary, document: ruleDocument } = payloadResult; const { title, summary, document: ruleDocument } = payloadResult;
setIsPublishing(true);
const editingId = const editingId =
typeof state.editingPublishedRuleId === "string" typeof state.editingPublishedRuleId === "string"
@@ -8,6 +8,7 @@ import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext"; import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisplayUrl";
import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp"; import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { import {
@@ -148,11 +149,7 @@ export function CommunityReviewScreen() {
? state.communityContext.trim() ? state.communityContext.trim()
: undefined; : undefined;
const avatarUrl = const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl);
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: null;
const structure = messages.create.community.communityStructure; const structure = messages.create.community.communityStructure;
const sizes = messages.create.community.communitySize.communitySizes; const sizes = messages.create.community.communitySize.communitySizes;
@@ -28,6 +28,7 @@ import {
} from "../../components/FinalReviewChipEditModal"; } from "../../components/FinalReviewChipEditModal";
import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal"; import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal";
import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal"; import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal";
import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisplayUrl";
import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor"; import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor";
import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation"; import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation";
import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep"; import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep";
@@ -251,11 +252,7 @@ export function FinalReviewScreen({
const descriptionEmptyHint = const descriptionEmptyHint =
variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined; variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined;
const avatarUrl = const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl);
typeof state.communityAvatarUrl === "string" &&
state.communityAvatarUrl.trim().length > 0
? state.communityAvatarUrl.trim()
: null;
const storedFallback = const storedFallback =
typeof state.previewSurfaceColor === "string" typeof state.previewSurfaceColor === "string"
? state.previewSurfaceColor ? state.previewSurfaceColor
@@ -1,4 +1,5 @@
import type { CreateFlowState } from "../types"; import type { CreateFlowState } from "../types";
import { forgetFlushedCommunityAvatarUrl } from "../../../../lib/create/flushPendingCommunityAvatar";
import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState"; import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState";
import { clearPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload"; import { clearPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload";
@@ -47,7 +48,10 @@ export function writeAnonymousCreateFlowState(value: CreateFlowState): void {
} }
} }
export function clearAnonymousCreateFlowStorage(): void { export function clearAnonymousCreateFlowStorage(options?: {
/** Keep the staged logo so a failed upload can retry. */
preservePendingAvatar?: boolean;
}): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
try { try {
window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY); window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY);
@@ -55,6 +59,8 @@ export function clearAnonymousCreateFlowStorage(): void {
} catch { } catch {
// ignore // ignore
} }
if (options?.preservePendingAvatar) return;
forgetFlushedCommunityAvatarUrl();
void clearPendingCommunityAvatarFile(); void clearPendingCommunityAvatarFile();
} }
+35 -11
View File
@@ -31,6 +31,17 @@ function isPurpose(x: string): x is CreateFlowUploadPurpose {
return x === "communityAvatar" || x === "customMethodAttachment"; return x === "communityAvatar" || x === "customMethodAttachment";
} }
function clientIp(request: NextRequest): string {
return (
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"unknown"
);
}
/** Same spacing as a signed-in upload, keyed by IP so a guest cannot fill the disk. */
const ANONYMOUS_COMMUNITY_AVATAR_MIN_INTERVAL_MS = 5_000;
function messageForValidationReason( function messageForValidationReason(
reason: CreateFlowUploadValidationReason, reason: CreateFlowUploadValidationReason,
): string { ): string {
@@ -54,7 +65,19 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
} }
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) {
let formData: FormData | null = null;
try {
formData = await request.formData();
} catch {
formData = null;
}
const purposeRaw = formData?.get("purpose");
// A guest publishes before they have a session. Only the community logo
// can be stored without one; attachments stay signed-in. A body that
// cannot be parsed is not a logo upload.
if (!user && purposeRaw !== "communityAvatar") {
return unauthorized(); return unauthorized();
} }
@@ -64,15 +87,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
); );
} }
const rl = rateLimitKey(`upload:${user.id}`, 5_000); if (!formData) {
if (rl.ok === false) {
return rateLimited(rl.retryAfterMs);
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return errorJson( return errorJson(
"payload_too_large", "payload_too_large",
"Upload body is too large or malformed.", "Upload body is too large or malformed.",
@@ -80,9 +95,18 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => {
); );
} }
const purposeRaw = formData.get("purpose");
const file = asUploadedBlob(formData.get("file")); const file = asUploadedBlob(formData.get("file"));
const rl = user
? rateLimitKey(`upload:${user.id}`, 5_000)
: rateLimitKey(
`upload-anon:${clientIp(request)}`,
ANONYMOUS_COMMUNITY_AVATAR_MIN_INTERVAL_MS,
);
if (rl.ok === false) {
return rateLimited(rl.retryAfterMs);
}
if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) { if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) {
return errorJson( return errorJson(
"validation_error", "validation_error",
+5 -3
View File
@@ -163,14 +163,16 @@ export function RuleView({
// Render logo/icon // Render logo/icon
const renderLogo = () => { const renderLogo = () => {
if (logoUrl) { if (logoUrl) {
// Check if it's a localhost URL or external URL that needs regular img tag // next/image cannot load blob previews or localhost optimizer targets.
const isLocalhost = const useNativeImage =
logoUrl.startsWith("blob:") ||
logoUrl.startsWith("data:") ||
logoUrl.startsWith("http://localhost") || logoUrl.startsWith("http://localhost") ||
logoUrl.startsWith("https://localhost"); logoUrl.startsWith("https://localhost");
const containerClass = `${logoContainerClass} relative overflow-hidden rounded-full bg-[var(--color-surface-invert-brand-teal)]`; const containerClass = `${logoContainerClass} relative overflow-hidden rounded-full bg-[var(--color-surface-invert-brand-teal)]`;
if (isLocalhost) { if (useNativeImage) {
return ( return (
<div className={containerClass}> <div className={containerClass}>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
+97
View File
@@ -0,0 +1,97 @@
import type { CreateFlowState } from "../../app/(app)/create/types";
import { CreateFlowUploadValidationError } from "./createFlowUploadValidation";
import {
clearPendingCommunityAvatarFile,
readPendingCommunityAvatarFile,
} from "./pendingCommunityAvatarUpload";
import { uploadCreateFlowFile } from "./uploadToServer";
export type FlushPendingCommunityAvatarResult =
| { status: "none" }
| { status: "uploaded"; url: string }
| { status: "invalid" }
| { status: "failed" };
/**
* URL from the last successful flush. The IndexedDB file is removed on
* success, so a second caller (publish, sign-in transfer) can still apply
* the same URL if React state has not caught up yet.
* Cleared when anonymous draft storage is wiped.
*/
let lastUploadedUrl: string | null = null;
let inFlight: Promise<FlushPendingCommunityAvatarResult> | null = null;
export function forgetFlushedCommunityAvatarUrl(): void {
lastUploadedUrl = null;
}
async function runFlush(): Promise<FlushPendingCommunityAvatarResult> {
let file: File | null;
try {
file = await readPendingCommunityAvatarFile();
} catch {
return lastUploadedUrl
? { status: "uploaded", url: lastUploadedUrl }
: { status: "none" };
}
if (!file) {
return lastUploadedUrl
? { status: "uploaded", url: lastUploadedUrl }
: { status: "none" };
}
try {
const { url } = await uploadCreateFlowFile(file, "communityAvatar");
lastUploadedUrl = url;
await clearPendingCommunityAvatarFile();
return { status: "uploaded", url };
} catch (err) {
if (err instanceof CreateFlowUploadValidationError) {
await clearPendingCommunityAvatarFile();
return { status: "invalid" };
}
return { status: "failed" };
}
}
/**
* Upload the staged community logo, once, even if review and sign-in both ask.
* A validation failure drops the file. A transient failure leaves it in place.
*/
export function flushPendingCommunityAvatar(): Promise<FlushPendingCommunityAvatarResult> {
if (!inFlight) {
inFlight = runFlush().finally(() => {
inFlight = null;
});
}
return inFlight;
}
/**
* Copy a staged logo onto `state` as `communityAvatarUrl` when upload works.
* `upload_failed` means the file is still staged and must not be published over.
*/
export async function stateWithPendingCommunityAvatar(
state: CreateFlowState,
): Promise<
| { ok: true; state: CreateFlowState }
| { ok: false; reason: "upload_failed" }
> {
const existing =
typeof state.communityAvatarUrl === "string"
? state.communityAvatarUrl.trim()
: "";
if (existing.length > 0) return { ok: true, state };
const flushed = await flushPendingCommunityAvatar();
if (flushed.status === "uploaded") {
return {
ok: true,
state: { ...state, communityAvatarUrl: flushed.url },
};
}
if (flushed.status === "failed") {
return { ok: false, reason: "upload_failed" };
}
return { ok: true, state };
}
+2 -2
View File
@@ -28,8 +28,8 @@ function reasonFromUnknown(value: unknown): CreateFlowUploadValidationReason | n
} }
/** /**
* Authenticated multipart upload to `POST /api/uploads`. * Multipart upload to `POST /api/uploads`.
* Caller must have a session cookie (same-origin fetch). * `communityAvatar` is accepted for a guest; other purposes need a session.
*/ */
export async function uploadCreateFlowFile( export async function uploadCreateFlowFile(
file: File, file: File,
@@ -0,0 +1,61 @@
import { act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CreateFlowPendingAvatarFlush } from "../../app/(app)/create/components/CreateFlowPendingAvatarFlush";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { flushPendingCommunityAvatar } from "../../lib/create/flushPendingCommunityAvatar";
import {
renderWithProviders as render,
screen,
} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
vi.mock("../../lib/create/flushPendingCommunityAvatar", () => ({
flushPendingCommunityAvatar: vi.fn(),
}));
function AvatarProbe() {
const { state } = useCreateFlow();
return <div data-testid="avatar">{state.communityAvatarUrl ?? ""}</div>;
}
describe("CreateFlowPendingAvatarFlush", () => {
afterEach(() => {
vi.useRealTimers();
vi.mocked(flushPendingCommunityAvatar).mockReset();
});
it("retries a transient upload and then stores the url", async () => {
vi.useFakeTimers();
vi.mocked(flushPendingCommunityAvatar)
.mockResolvedValueOnce({ status: "failed" })
.mockResolvedValueOnce({
status: "uploaded",
url: "/api/uploads/retry",
});
render(
<>
<CreateFlowPendingAvatarFlush
sessionUser={{ id: "user-1", email: "maya@example.com" }}
sessionResolved
/>
<AvatarProbe />
</>,
);
await act(async () => {
await Promise.resolve();
});
expect(flushPendingCommunityAvatar).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("avatar")).toHaveTextContent("");
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(flushPendingCommunityAvatar).toHaveBeenCalledTimes(2);
expect(screen.getByTestId("avatar")).toHaveTextContent(
"/api/uploads/retry",
);
});
});
@@ -0,0 +1,120 @@
import { useSearchParams } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { PostLoginDraftTransfer } from "../../app/(app)/create/PostLoginDraftTransfer";
import {
CREATE_FLOW_ANONYMOUS_KEY,
CREATE_FLOW_TRANSFER_PENDING_KEY,
} from "../../app/(app)/create/utils/anonymousDraftStorage";
import { saveDraftToServer } from "../../lib/create/api";
import { flushPendingCommunityAvatar } from "../../lib/create/flushPendingCommunityAvatar";
import { clearPendingCommunityAvatarFile } from "../../lib/create/pendingCommunityAvatarUpload";
import {
renderWithProviders as render,
screen,
waitFor,
} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
vi.mock("../../lib/create/api", () => ({
fetchDraftFromServer: vi.fn().mockResolvedValue(null),
saveDraftToServer: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock("../../lib/create/flushPendingCommunityAvatar", () => ({
flushPendingCommunityAvatar: vi.fn(),
forgetFlushedCommunityAvatarUrl: vi.fn(),
}));
vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({
clearPendingCommunityAvatarFile: vi.fn().mockResolvedValue(undefined),
readPendingCommunityAvatarFile: vi.fn().mockResolvedValue(null),
storePendingCommunityAvatarFile: vi.fn(),
}));
function mockSearchParams(value: string) {
vi.mocked(useSearchParams).mockReturnValue(
new URLSearchParams(value) as NonNullable<
ReturnType<typeof useSearchParams>
>,
);
}
function StateProbe() {
const { state } = useCreateFlow();
return (
<div>
<span data-testid="title">{state.title ?? ""}</span>
<span data-testid="avatar">{state.communityAvatarUrl ?? ""}</span>
</div>
);
}
describe("PostLoginDraftTransfer", () => {
beforeEach(() => {
localStorage.clear();
localStorage.setItem(
CREATE_FLOW_ANONYMOUS_KEY,
JSON.stringify({ title: "Garden Club" }),
);
localStorage.setItem(CREATE_FLOW_TRANSFER_PENDING_KEY, "1");
mockSearchParams("syncDraft=1");
vi.mocked(flushPendingCommunityAvatar).mockReset();
vi.mocked(saveDraftToServer).mockClear();
vi.mocked(clearPendingCommunityAvatarFile).mockClear();
});
it("uploads the staged logo before clearing the anonymous draft", async () => {
let draftStillPresent = false;
vi.mocked(flushPendingCommunityAvatar).mockImplementation(async () => {
draftStillPresent =
localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY) != null;
return { status: "uploaded", url: "/api/uploads/from-guest" };
});
render(
<>
<PostLoginDraftTransfer
sessionUser={{ id: "user-1", email: "maya@example.com" }}
/>
<StateProbe />
</>,
);
await waitFor(() => {
expect(screen.getByTestId("avatar")).toHaveTextContent(
"/api/uploads/from-guest",
);
});
expect(draftStillPresent).toBe(true);
expect(localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
expect(saveDraftToServer).toHaveBeenCalledWith(
expect.objectContaining({
title: "Garden Club",
communityAvatarUrl: "/api/uploads/from-guest",
}),
);
expect(clearPendingCommunityAvatarFile).toHaveBeenCalled();
});
it("keeps the staged file when the upload fails", async () => {
vi.mocked(flushPendingCommunityAvatar).mockResolvedValue({
status: "failed",
});
render(
<>
<PostLoginDraftTransfer
sessionUser={{ id: "user-1", email: "maya@example.com" }}
/>
<StateProbe />
</>,
);
await waitFor(() => {
expect(screen.getByTestId("title")).toHaveTextContent("Garden Club");
});
expect(clearPendingCommunityAvatarFile).not.toHaveBeenCalled();
expect(screen.getByTestId("avatar")).toHaveTextContent("");
});
});
+37 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, it, expect } from "vitest"; import { beforeEach, describe, it, expect, vi } from "vitest";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { import {
renderWithProviders as render, renderWithProviders as render,
@@ -9,6 +9,13 @@ import "@testing-library/jest-dom/vitest";
import { CommunityReviewScreen } from "../../app/(app)/create/screens/review/CommunityReviewScreen"; import { CommunityReviewScreen } from "../../app/(app)/create/screens/review/CommunityReviewScreen";
import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext"; import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
import { testRouter } from "../mocks/navigation"; import { testRouter } from "../mocks/navigation";
import { readPendingCommunityAvatarFile } from "../../lib/create/pendingCommunityAvatarUpload";
vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({
readPendingCommunityAvatarFile: vi.fn().mockResolvedValue(null),
storePendingCommunityAvatarFile: vi.fn(),
clearPendingCommunityAvatarFile: vi.fn(),
}));
function ReviewWithTitle({ title }: { title: string }) { function ReviewWithTitle({ title }: { title: string }) {
const { state, updateState } = useCreateFlow(); const { state, updateState } = useCreateFlow();
@@ -43,6 +50,8 @@ describe("CommunityReviewScreen", () => {
beforeEach(() => { beforeEach(() => {
testRouter.replace.mockReset(); testRouter.replace.mockReset();
testRouter.push.mockReset(); testRouter.push.mockReset();
vi.mocked(readPendingCommunityAvatarFile).mockReset();
vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(null);
}); });
it("renders without crashing", () => { it("renders without crashing", () => {
@@ -89,6 +98,33 @@ describe("CommunityReviewScreen", () => {
expect(screen.getByText("Garden Club")).toBeInTheDocument(); expect(screen.getByText("Garden Club")).toBeInTheDocument();
}); });
it("shows a guest logo staged in the browser on the rule card", async () => {
const pending = new File([new Uint8Array([1, 2, 3])], "logo.png", {
type: "image/png",
});
vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(pending);
Object.defineProperty(URL, "createObjectURL", {
value: vi.fn(() => "blob:pending-avatar"),
writable: true,
configurable: true,
});
Object.defineProperty(URL, "revokeObjectURL", {
value: vi.fn(),
writable: true,
configurable: true,
});
render(<ReviewWithTitle title="Garden Club" />);
await waitFor(() => {
expect(screen.getByRole("img", { name: "Garden Club" })).toHaveAttribute(
"src",
"blob:pending-avatar",
);
});
expect(readPendingCommunityAvatarFile).toHaveBeenCalled();
});
it("omits the Rule description when the user has not entered community context", () => { it("omits the Rule description when the user has not entered community context", () => {
render(<ReviewWithTitle title="Garden Club" />); render(<ReviewWithTitle title="Garden Club" />);
expect( expect(
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const clearPendingCommunityAvatarFile = vi.fn();
const forgetFlushedCommunityAvatarUrl = vi.fn();
vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({
clearPendingCommunityAvatarFile: () => clearPendingCommunityAvatarFile(),
}));
vi.mock("../../lib/create/flushPendingCommunityAvatar", () => ({
forgetFlushedCommunityAvatarUrl: () => forgetFlushedCommunityAvatarUrl(),
}));
import {
CREATE_FLOW_ANONYMOUS_KEY,
CREATE_FLOW_TRANSFER_PENDING_KEY,
clearAnonymousCreateFlowStorage,
} from "../../app/(app)/create/utils/anonymousDraftStorage";
describe("clearAnonymousCreateFlowStorage", () => {
beforeEach(() => {
localStorage.clear();
clearPendingCommunityAvatarFile.mockReset();
forgetFlushedCommunityAvatarUrl.mockReset();
localStorage.setItem(CREATE_FLOW_ANONYMOUS_KEY, "{\"title\":\"Garden\"}");
localStorage.setItem(CREATE_FLOW_TRANSFER_PENDING_KEY, "1");
});
it("removes the draft and the staged logo", () => {
clearAnonymousCreateFlowStorage();
expect(localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
expect(localStorage.getItem(CREATE_FLOW_TRANSFER_PENDING_KEY)).toBeNull();
expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1);
expect(forgetFlushedCommunityAvatarUrl).toHaveBeenCalledTimes(1);
});
it("keeps the staged logo when a transfer upload still needs to retry", () => {
clearAnonymousCreateFlowStorage({ preservePendingAvatar: true });
expect(localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
expect(clearPendingCommunityAvatarFile).not.toHaveBeenCalled();
expect(forgetFlushedCommunityAvatarUrl).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CreateFlowUploadValidationError } from "../../lib/create/createFlowUploadValidation";
const readPendingCommunityAvatarFile = vi.fn();
const clearPendingCommunityAvatarFile = vi.fn();
const uploadCreateFlowFile = vi.fn();
vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({
readPendingCommunityAvatarFile: (...args: unknown[]) =>
readPendingCommunityAvatarFile(...args),
clearPendingCommunityAvatarFile: (...args: unknown[]) =>
clearPendingCommunityAvatarFile(...args),
}));
vi.mock("../../lib/create/uploadToServer", () => ({
uploadCreateFlowFile: (...args: unknown[]) => uploadCreateFlowFile(...args),
}));
import {
flushPendingCommunityAvatar,
forgetFlushedCommunityAvatarUrl,
stateWithPendingCommunityAvatar,
} from "../../lib/create/flushPendingCommunityAvatar";
const pending = new File([new Uint8Array([1, 2, 3])], "logo.png", {
type: "image/png",
});
describe("flushPendingCommunityAvatar", () => {
beforeEach(() => {
readPendingCommunityAvatarFile.mockReset();
clearPendingCommunityAvatarFile.mockReset();
uploadCreateFlowFile.mockReset();
clearPendingCommunityAvatarFile.mockResolvedValue(undefined);
forgetFlushedCommunityAvatarUrl();
});
it("does nothing when no file is staged", async () => {
readPendingCommunityAvatarFile.mockResolvedValue(null);
await expect(flushPendingCommunityAvatar()).resolves.toEqual({
status: "none",
});
expect(uploadCreateFlowFile).not.toHaveBeenCalled();
});
it("uploads once and remembers the url after the file is cleared", async () => {
readPendingCommunityAvatarFile
.mockResolvedValueOnce(pending)
.mockResolvedValue(null);
uploadCreateFlowFile.mockResolvedValue({ url: "/api/uploads/logo" });
const [first, second] = await Promise.all([
flushPendingCommunityAvatar(),
flushPendingCommunityAvatar(),
]);
expect(first).toEqual({ status: "uploaded", url: "/api/uploads/logo" });
expect(second).toEqual(first);
expect(uploadCreateFlowFile).toHaveBeenCalledTimes(1);
expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1);
await expect(flushPendingCommunityAvatar()).resolves.toEqual({
status: "uploaded",
url: "/api/uploads/logo",
});
expect(uploadCreateFlowFile).toHaveBeenCalledTimes(1);
});
it("drops an invalid file and leaves a transient failure staged", async () => {
readPendingCommunityAvatarFile.mockResolvedValue(pending);
uploadCreateFlowFile.mockRejectedValueOnce(
new CreateFlowUploadValidationError("tooLarge"),
);
await expect(flushPendingCommunityAvatar()).resolves.toEqual({
status: "invalid",
});
expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1);
uploadCreateFlowFile.mockRejectedValueOnce(new Error("network"));
await expect(flushPendingCommunityAvatar()).resolves.toEqual({
status: "failed",
});
expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1);
});
it("copies a successful upload onto publish state and blocks on failure", async () => {
readPendingCommunityAvatarFile.mockResolvedValue(pending);
uploadCreateFlowFile.mockResolvedValue({ url: "/api/uploads/logo" });
await expect(
stateWithPendingCommunityAvatar({ title: "Garden" }),
).resolves.toEqual({
ok: true,
state: { title: "Garden", communityAvatarUrl: "/api/uploads/logo" },
});
forgetFlushedCommunityAvatarUrl();
uploadCreateFlowFile.mockRejectedValueOnce(new Error("network"));
await expect(
stateWithPendingCommunityAvatar({
title: "Garden",
communityAvatarFileName: "logo.png",
}),
).resolves.toEqual({ ok: false, reason: "upload_failed" });
});
});
@@ -3,6 +3,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { CreateFlowState } from "../../../app/(app)/create/types"; import type { CreateFlowState } from "../../../app/(app)/create/types";
import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize"; import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize";
import { publishRule, updatePublishedRule } from "../../../lib/create/api"; import { publishRule, updatePublishedRule } from "../../../lib/create/api";
import { buildPublishPayload } from "../../../lib/create/buildPublishPayload";
import { stateWithPendingCommunityAvatar } from "../../../lib/create/flushPendingCommunityAvatar";
import messages from "../../../messages/en/index";
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule"; import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import { CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY } from "../../../lib/create/pendingKeepRuleLogin"; import { CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY } from "../../../lib/create/pendingKeepRuleLogin";
import { import {
@@ -24,6 +27,13 @@ vi.mock("../../../lib/create/api", () => ({
updatePublishedRule: vi.fn(), updatePublishedRule: vi.fn(),
})); }));
vi.mock("../../../lib/create/flushPendingCommunityAvatar", () => ({
stateWithPendingCommunityAvatar: vi.fn(async (state: CreateFlowState) => ({
ok: true as const,
state,
})),
}));
vi.mock("../../../lib/create/lastPublishedRule", () => ({ vi.mock("../../../lib/create/lastPublishedRule", () => ({
writeLastPublishedRule: vi.fn(), writeLastPublishedRule: vi.fn(),
})); }));
@@ -41,6 +51,10 @@ describe("useCreateFlowFinalize", () => {
vi.mocked(publishRule).mockReset(); vi.mocked(publishRule).mockReset();
vi.mocked(updatePublishedRule).mockReset(); vi.mocked(updatePublishedRule).mockReset();
vi.mocked(writeLastPublishedRule).mockReset(); vi.mocked(writeLastPublishedRule).mockReset();
vi.mocked(stateWithPendingCommunityAvatar).mockReset();
vi.mocked(stateWithPendingCommunityAvatar).mockImplementation(
async (state) => ({ ok: true as const, state }),
);
router.push.mockReset(); router.push.mockReset();
updateState.mockReset(); updateState.mockReset();
openLogin.mockReset(); openLogin.mockReset();
@@ -148,6 +162,82 @@ describe("useCreateFlowFinalize", () => {
); );
}); });
it("publishes a guest logo that was only staged in the browser", async () => {
vi.mocked(publishRule).mockResolvedValue({
ok: true,
id: "guest-rule-id",
title: "Published title",
});
vi.mocked(stateWithPendingCommunityAvatar).mockResolvedValue({
ok: true,
state: {
communityAvatarFileName: "logo.png",
communityAvatarUrl: "/api/uploads/guest-logo",
},
});
vi.mocked(buildPublishPayload).mockImplementationOnce((draft) => ({
ok: true as const,
title: "Published title",
summary: "Published summary",
document: {
communityAvatarUrl: draft.communityAvatarUrl,
},
}));
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: { communityAvatarFileName: "logo.png" },
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review",
sessionUser: null,
onGuestPublished,
}),
);
await act(async () => {
await result.current.finalize();
});
expect(publishRule).toHaveBeenCalledWith({
title: "Published title",
summary: "Published summary",
document: { communityAvatarUrl: "/api/uploads/guest-logo" },
});
expect(updateState).toHaveBeenCalledWith({
communityAvatarUrl: "/api/uploads/guest-logo",
});
});
it("does not publish when the staged logo fails to upload", async () => {
vi.mocked(stateWithPendingCommunityAvatar).mockResolvedValue({
ok: false,
reason: "upload_failed",
});
const { result } = renderHook(() =>
useCreateFlowFinalize({
state: { communityAvatarFileName: "logo.png" },
router,
openLogin,
updateState,
loginReturnPath: "/create/final-review",
sessionUser: null,
}),
);
await act(async () => {
await result.current.finalize();
});
expect(publishRule).not.toHaveBeenCalled();
expect(result.current.publishBannerMessage).toBe(
messages.create.upload.errors.generic,
);
expect(result.current.isPublishing).toBe(false);
});
it("hints to claim later when a guest publishes without stakeholder emails", async () => { it("hints to claim later when a guest publishes without stakeholder emails", async () => {
vi.mocked(publishRule).mockResolvedValue({ vi.mocked(publishRule).mockResolvedValue({
ok: true, ok: true,
+33
View File
@@ -80,6 +80,39 @@ describe("POST /api/uploads", () => {
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it("returns 401 when a guest uploads an attachment", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
const res = await POST(
multipartRequest({
purpose: "customMethodAttachment",
fileName: "notes.pdf",
fileContent: "hello",
contentType: "application/pdf",
}),
undefined,
);
expect(res.status).toBe(401);
});
it("lets a guest submit a community logo and then validates it", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
const res = await POST(
multipartRequest({
purpose: "communityAvatar",
fileName: "empty.png",
fileContent: "",
}),
undefined,
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
error: { code: string };
details?: { reason?: string };
};
expect(body.error.code).toBe("validation_error");
expect(body.details?.reason).toBe("empty");
});
it("returns 500 when UPLOAD_ROOT is unset", async () => { it("returns 500 when UPLOAD_ROOT is unset", async () => {
getUploadRootFromEnvMock.mockReturnValueOnce(null); getUploadRootFromEnvMock.mockReturnValueOnce(null);
const res = await POST( const res = await POST(