Keep a guest community logo on review and on the published rule.
Sign-in was clearing the staged file before it uploaded, and review only read the server URL, so a chosen logo disappeared after the upload step.
This commit is contained in:
1 parent
2348a9e94f
commit
8212423ec3
19 files changed
+780
-61
No files matched your search
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in new issue
Block a user