From c70d9594ba8bf11620a758963dc83da2b4d81b02 Mon Sep 17 00:00:00 2001 From: edmonday Date: Thu, 21 May 2026 04:00:13 +0000 Subject: [PATCH 1/4] fix(media): filter getMyCloudflareImages to uploaded:true and mark uploads complete [NES-1689] Adds uploaded:true to the getMyCloudflareImages where clause and wires the cloudflareUploadComplete mutation in ImageUpload so successful direct-file uploads flip the row to uploaded:true. Together these prevent orphan rows from abandoned/failed Cloudflare POSTs from surfacing as broken image tiles in the picker grid. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/schema/cloudflare/image/image.spec.ts | 20 +- .../src/schema/cloudflare/image/image.ts | 1 + .../__generated__/CloudflareUploadComplete.ts | 16 ++ .../__generated__/NewCloudflareImage.ts | 15 ++ .../ImageUpload/ImageUpload.spec.tsx | 214 +++++++++++++++++- .../CustomImage/ImageUpload/ImageUpload.tsx | 17 +- 6 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 apps/journeys-admin/__generated__/CloudflareUploadComplete.ts create mode 100644 apps/journeys-admin/__generated__/NewCloudflareImage.ts diff --git a/apis/api-media/src/schema/cloudflare/image/image.spec.ts b/apis/api-media/src/schema/cloudflare/image/image.spec.ts index b6e3dc907aa..1aafecdeac6 100644 --- a/apis/api-media/src/schema/cloudflare/image/image.spec.ts +++ b/apis/api-media/src/schema/cloudflare/image/image.spec.ts @@ -224,7 +224,7 @@ describe('cloudflareImage', () => { } }) expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith({ - where: { userId: 'testUserId' }, + where: { userId: 'testUserId', uploaded: true }, orderBy: { createdAt: 'desc' } }) }) @@ -281,7 +281,7 @@ describe('cloudflareImage', () => { } }) expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith({ - where: { userId: 'testUserId' }, + where: { userId: 'testUserId', uploaded: true }, orderBy: { createdAt: 'desc' }, take: 10, skip: 0 @@ -295,7 +295,7 @@ describe('cloudflareImage', () => { variables: { isAi: true } }) expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith({ - where: { userId: 'testUserId', isAi: true }, + where: { userId: 'testUserId', uploaded: true, isAi: true }, orderBy: { createdAt: 'desc' } }) }) @@ -307,7 +307,7 @@ describe('cloudflareImage', () => { variables: { isAi: false } }) expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith({ - where: { userId: 'testUserId', isAi: false }, + where: { userId: 'testUserId', uploaded: true, isAi: false }, orderBy: { createdAt: 'desc' } }) }) @@ -319,10 +319,20 @@ describe('cloudflareImage', () => { variables: { isAi: null } }) expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith({ - where: { userId: 'testUserId' }, + where: { userId: 'testUserId', uploaded: true }, orderBy: { createdAt: 'desc' } }) }) + + it('should exclude images where uploaded is false', async () => { + prismaMock.cloudflareImage.findMany.mockResolvedValue([]) + await authClient({ document: GET_MY_CLOUDFLARE_IMAGES_QUERY }) + expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ uploaded: true }) + }) + ) + }) }) describe('getMyCloudflareImage', () => { diff --git a/apis/api-media/src/schema/cloudflare/image/image.ts b/apis/api-media/src/schema/cloudflare/image/image.ts index 299c3dd5440..91a8e07e22c 100644 --- a/apis/api-media/src/schema/cloudflare/image/image.ts +++ b/apis/api-media/src/schema/cloudflare/image/image.ts @@ -85,6 +85,7 @@ builder.queryFields((t) => ({ ...query, where: { userId: user.id, + uploaded: true, ...(isAi != null ? { isAi } : {}) }, orderBy: { createdAt: 'desc' }, diff --git a/apps/journeys-admin/__generated__/CloudflareUploadComplete.ts b/apps/journeys-admin/__generated__/CloudflareUploadComplete.ts new file mode 100644 index 00000000000..3668e84acb5 --- /dev/null +++ b/apps/journeys-admin/__generated__/CloudflareUploadComplete.ts @@ -0,0 +1,16 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: CloudflareUploadComplete +// ==================================================== + +export interface CloudflareUploadComplete { + cloudflareUploadComplete: boolean; +} + +export interface CloudflareUploadCompleteVariables { + id: string; +} diff --git a/apps/journeys-admin/__generated__/NewCloudflareImage.ts b/apps/journeys-admin/__generated__/NewCloudflareImage.ts new file mode 100644 index 00000000000..5952886fe52 --- /dev/null +++ b/apps/journeys-admin/__generated__/NewCloudflareImage.ts @@ -0,0 +1,15 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL fragment: NewCloudflareImage +// ==================================================== + +export interface NewCloudflareImage { + __typename: "CloudflareImage"; + id: string; + url: string | null; + blurhash: string | null; +} diff --git a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx index bb5d2f6d31e..4759f6ca3e6 100644 --- a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx +++ b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx @@ -6,7 +6,19 @@ import fetch, { Response } from 'node-fetch' import { BlockFields_ImageBlock as ImageBlock } from '../../../../../../../../../__generated__/BlockFields' import { CREATE_CLOUDFLARE_UPLOAD_BY_FILE } from '../../../../../../../../libs/useCloudflareUploadByFileMutation/useCloudflareUploadByFileMutation' -import { ImageUpload } from './ImageUpload' +import { CLOUDFLARE_UPLOAD_COMPLETE, ImageUpload } from './ImageUpload' + +const cloudflareUploadCompleteMockResult = jest.fn(() => ({ + data: { cloudflareUploadComplete: true } +})) + +const cloudflareUploadCompleteMock = { + request: { + query: CLOUDFLARE_UPLOAD_COMPLETE, + variables: { id: 'uploadId' } + }, + result: cloudflareUploadCompleteMockResult +} jest.mock('node-fetch', () => { const originalModule = jest.requireActual('node-fetch') @@ -40,6 +52,7 @@ describe('ImageUpload', () => { afterEach(() => { process.env = originalEnv mockSendGTMEvent.mockClear() + cloudflareUploadCompleteMockResult.mockClear() }) const imageBlock: ImageBlock = { @@ -139,7 +152,8 @@ describe('ImageUpload', () => { } } } - } + }, + cloudflareUploadCompleteMock ]} > { focalTop: 50 }) ) + expect(cloudflareUploadCompleteMockResult).toHaveBeenCalled() expect(screen.getByText('Upload Successful!')).toBeInTheDocument() }) + it('should not call cloudflareUploadComplete on rejected file', async () => { + render( + + + + ) + const inputEl = screen.getByTestId('drop zone') + const largeFile = new File([new ArrayBuffer(11000000)], 'large.png', { + type: 'image/png' + }) + Object.defineProperty(inputEl, 'files', { value: [largeFile] }) + fireEvent.drop(inputEl) + + await waitFor(() => + expect(screen.getByText('Upload Failed!')).toBeInTheDocument() + ) + expect(cloudflareUploadCompleteMockResult).not.toHaveBeenCalled() + }) + + it('should not call cloudflareUploadComplete on Cloudflare error response', async () => { + const cfErrorResponse = { + result: { id: 'uploadId' }, + errors: [{ code: 5000, message: 'Upload failed' }], + messages: [], + success: false + } + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => await Promise.resolve(cfErrorResponse) + } as unknown as Response) + + render( + + + + ) + const inputEl = screen.getByTestId('drop zone') + Object.defineProperty(inputEl, 'files', { + value: [ + new File([new Blob(['file'])], 'testFile.png', { type: 'image/png' }) + ] + }) + fireEvent.drop(inputEl) + + await waitFor(() => + expect(screen.getByText('Upload Failed!')).toBeInTheDocument() + ) + expect(cloudflareUploadCompleteMockResult).not.toHaveBeenCalled() + }) + + it('should not call cloudflareUploadComplete on fetch exception', async () => { + mockFetch.mockRejectedValueOnce(new Error('network')) + + render( + + + + ) + const inputEl = screen.getByTestId('drop zone') + Object.defineProperty(inputEl, 'files', { + value: [ + new File([new Blob(['file'])], 'testFile.png', { type: 'image/png' }) + ] + }) + fireEvent.drop(inputEl) + + await waitFor(() => + expect(mockSendGTMEvent).toHaveBeenCalledWith({ + event: 'image_upload_failure', + fileSize: expect.any(Number), + fileType: 'image/png', + errorCode: 'upload-exception' + }) + ) + expect(cloudflareUploadCompleteMockResult).not.toHaveBeenCalled() + }) + + it('should not call cloudflareUploadComplete on invalid response (missing cloudflareId)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => + await Promise.resolve({ + result: null, + errors: [], + messages: [], + success: true + }) + } as unknown as Response) + + render( + + + + ) + const inputEl = screen.getByTestId('drop zone') + Object.defineProperty(inputEl, 'files', { + value: [ + new File([new Blob(['file'])], 'testFile.png', { type: 'image/png' }) + ] + }) + fireEvent.drop(inputEl) + + await waitFor(() => + expect(mockSendGTMEvent).toHaveBeenCalledWith({ + event: 'image_upload_failure', + fileSize: expect.any(Number), + fileType: 'image/png', + errorCode: 'upload-invalid-response' + }) + ) + expect(cloudflareUploadCompleteMockResult).not.toHaveBeenCalled() + }) + it('should render drop zone text in default state', () => { render( @@ -226,7 +421,8 @@ describe('ImageUpload', () => { } } } - } + }, + cloudflareUploadCompleteMock ]} > { } } } - } + }, + cloudflareUploadCompleteMock ]} > { } } } - } + }, + cloudflareUploadCompleteMock ]} > { } } } - } + }, + cloudflareUploadCompleteMock ]} > { } } } - } + }, + cloudflareUploadCompleteMock ]} > void setUploading?: (uploading?: boolean) => void @@ -43,6 +53,10 @@ export function ImageUpload({ const { t } = useTranslation('apps-journeys-admin') const { cache } = useApolloClient() const [createCloudflareUploadByFile] = useCloudflareUploadByFileMutation() + const [cloudflareUploadComplete] = useMutation< + CloudflareUploadComplete, + CloudflareUploadCompleteVariables + >(CLOUDFLARE_UPLOAD_COMPLETE) const [success, setSuccess] = useState(undefined) const [errorCode, setErrorCode] = useState() const successResetTimerRef = useRef | null>( @@ -129,6 +143,7 @@ export function ImageUpload({ return } const url = `https://imagedelivery.net/${cloudflareUploadKey}/${cloudflareId}` + await cloudflareUploadComplete({ variables: { id: cloudflareId } }) prependCloudflareImage( cache, { id: cloudflareId, url, blurhash: null }, From fa5ef403c0b68cf4a88e1564a74b457c4bb7b3ae Mon Sep 17 00:00:00 2001 From: edmonday Date: Thu, 21 May 2026 05:00:23 +0000 Subject: [PATCH 2/4] fix(media): extract cloudflareUploadComplete hook and wire all upload surfaces [NES-1689] Addresses ce-review findings: P0 - wire cloudflareUploadComplete into useImageUpload and SocialScreenSocialImage so images uploaded through those surfaces flip uploaded=true; without this, the new BE filter would permanently hide them. P1 - split try/catch in ImageUpload so a cloudflareUploadComplete failure reports upload-mark-complete-failed instead of mislabeling as upload-exception (which leaked the Cloudflare asset). P2 - extract useCloudflareUploadCompleteMutation to libs/ following the useCloudflareUploadByFileMutation pattern; defer setSuccess(true) until after the mutation resolves; add a spec for the mutation-failure path; drop a redundant uploaded:true filter assertion. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/schema/cloudflare/image/image.spec.ts | 9 - .../ImageUpload/ImageUpload.spec.tsx | 66 +++++- .../CustomImage/ImageUpload/ImageUpload.tsx | 197 ++++++++++-------- .../SocialScreenSocialImage.spec.tsx | 7 +- .../SocialScreenSocialImage.tsx | 6 +- .../index.ts | 4 + ...seCloudflareUploadCompleteMutation.mock.ts | 22 ++ .../useCloudflareUploadCompleteMutation.ts | 29 +++ .../useImageUpload/useImageUpload.spec.tsx | 12 ++ .../src/libs/useImageUpload/useImageUpload.ts | 18 +- 10 files changed, 262 insertions(+), 108 deletions(-) create mode 100644 apps/journeys-admin/src/libs/useCloudflareUploadCompleteMutation/index.ts create mode 100644 apps/journeys-admin/src/libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation.mock.ts create mode 100644 apps/journeys-admin/src/libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation.ts diff --git a/apis/api-media/src/schema/cloudflare/image/image.spec.ts b/apis/api-media/src/schema/cloudflare/image/image.spec.ts index 1aafecdeac6..1049083891b 100644 --- a/apis/api-media/src/schema/cloudflare/image/image.spec.ts +++ b/apis/api-media/src/schema/cloudflare/image/image.spec.ts @@ -324,15 +324,6 @@ describe('cloudflareImage', () => { }) }) - it('should exclude images where uploaded is false', async () => { - prismaMock.cloudflareImage.findMany.mockResolvedValue([]) - await authClient({ document: GET_MY_CLOUDFLARE_IMAGES_QUERY }) - expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ uploaded: true }) - }) - ) - }) }) describe('getMyCloudflareImage', () => { diff --git a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx index 4759f6ca3e6..63f877a6c76 100644 --- a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx +++ b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.spec.tsx @@ -5,8 +5,9 @@ import fetch, { Response } from 'node-fetch' import { BlockFields_ImageBlock as ImageBlock } from '../../../../../../../../../__generated__/BlockFields' import { CREATE_CLOUDFLARE_UPLOAD_BY_FILE } from '../../../../../../../../libs/useCloudflareUploadByFileMutation/useCloudflareUploadByFileMutation' +import { CLOUDFLARE_UPLOAD_COMPLETE } from '../../../../../../../../libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation' -import { CLOUDFLARE_UPLOAD_COMPLETE, ImageUpload } from './ImageUpload' +import { ImageUpload } from './ImageUpload' const cloudflareUploadCompleteMockResult = jest.fn(() => ({ data: { cloudflareUploadComplete: true } @@ -20,6 +21,14 @@ const cloudflareUploadCompleteMock = { result: cloudflareUploadCompleteMockResult } +const cloudflareUploadCompleteErrorMock = { + request: { + query: CLOUDFLARE_UPLOAD_COMPLETE, + variables: { id: 'uploadId' } + }, + error: new Error('cloudflareUploadComplete failed') +} + jest.mock('node-fetch', () => { const originalModule = jest.requireActual('node-fetch') return { @@ -977,4 +986,59 @@ describe('ImageUpload', () => { }) ) }) + + it('should report upload-mark-complete-failed and skip onChange when cloudflareUploadComplete throws', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => await Promise.resolve(cfResponse) + } as unknown as Response) + + const onChange = jest.fn() + const setUploading = jest.fn() + render( + + + + ) + const inputEl = screen.getByTestId('drop zone') + Object.defineProperty(inputEl, 'files', { + value: [ + new File([new Blob(['file'])], 'testFile.png', { type: 'image/png' }) + ] + }) + fireEvent.drop(inputEl) + + await waitFor(() => + expect(mockSendGTMEvent).toHaveBeenCalledWith({ + event: 'image_upload_failure', + fileSize: expect.any(Number), + fileType: 'image/png', + errorCode: 'upload-mark-complete-failed' + }) + ) + expect(onChange).not.toHaveBeenCalled() + expect(setUploading).toHaveBeenCalledWith(false) + }) }) diff --git a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx index 36bee920637..b9f1e1e5fcd 100644 --- a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx +++ b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx @@ -1,4 +1,4 @@ -import { gql, useApolloClient, useMutation } from '@apollo/client' +import { useApolloClient } from '@apollo/client' import Button from '@mui/material/Button' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' @@ -13,26 +13,17 @@ import CheckBrokenIcon from '@core/shared/ui/icons/CheckBroken' import Upload1IconIcon from '@core/shared/ui/icons/Upload1' import { BlockFields_ImageBlock as ImageBlock } from '../../../../../../../../../__generated__/BlockFields' -import { - CloudflareUploadComplete, - CloudflareUploadCompleteVariables -} from '../../../../../../../../../__generated__/CloudflareUploadComplete' import { ImageBlockUpdateInput } from '../../../../../../../../../__generated__/globalTypes' import { sendImageUploadFailureEvent, sendImageUploadSuccessEvent } from '../../../../../../../../libs/sendImageUploadEvent' import { useCloudflareUploadByFileMutation } from '../../../../../../../../libs/useCloudflareUploadByFileMutation' +import { useCloudflareUploadCompleteMutation } from '../../../../../../../../libs/useCloudflareUploadCompleteMutation' import { MAX_IMAGE_UPLOAD_BYTES } from '../../../../../../../../libs/useImageUpload' import { UploadDropZoneShell } from '../../../UploadDropZoneShell' import { prependCloudflareImage } from '../../MediaLibrary/prependCloudflareImage' -export const CLOUDFLARE_UPLOAD_COMPLETE = gql` - mutation CloudflareUploadComplete($id: ID!) { - cloudflareUploadComplete(id: $id) - } -` - interface ImageUploadProps { onChange: (input: ImageBlockUpdateInput) => void setUploading?: (uploading?: boolean) => void @@ -53,10 +44,7 @@ export function ImageUpload({ const { t } = useTranslation('apps-journeys-admin') const { cache } = useApolloClient() const [createCloudflareUploadByFile] = useCloudflareUploadByFileMutation() - const [cloudflareUploadComplete] = useMutation< - CloudflareUploadComplete, - CloudflareUploadCompleteVariables - >(CLOUDFLARE_UPLOAD_COMPLETE) + const [cloudflareUploadComplete] = useCloudflareUploadCompleteMutation() const [success, setSuccess] = useState(undefined) const [errorCode, setErrorCode] = useState() const successResetTimerRef = useRef | null>( @@ -97,87 +85,112 @@ export function ImageUpload({ setSuccess(undefined) setErrorCode(undefined) - if (data?.createCloudflareUploadByFile?.uploadUrl != null) { - const file = acceptedFiles[0] - const formData = new FormData() - formData.append('file', file) + if (data?.createCloudflareUploadByFile?.uploadUrl == null) return - const uploadUrl = data.createCloudflareUploadByFile.uploadUrl - try { - const response = await ( - await fetch(uploadUrl, { - method: 'POST', - body: formData as unknown as FormDataType - }) - ).json() + const file = acceptedFiles[0] + const formData = new FormData() + formData.append('file', file) + const uploadUrl = data.createCloudflareUploadByFile.uploadUrl - response.success === true ? setSuccess(true) : setSuccess(false) - if (response.errors?.length) { - const cloudflareError = response.errors[0].code - setSuccess(false) - setUploading?.(false) - setErrorCode(cloudflareError as ErrorCode) - sendImageUploadFailureEvent({ - fileSize: file.size, - fileType: file.type, - errorCode: String(cloudflareError) - }) - return - } - - const cloudflareId = response?.result?.id - const cloudflareUploadKey = - process.env.NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY - if ( - cloudflareId == null || - cloudflareUploadKey == null || - cloudflareUploadKey === '' - ) { - setSuccess(false) - setUploading?.(false) - sendImageUploadFailureEvent({ - fileSize: file.size, - fileType: file.type, - errorCode: 'upload-invalid-response' - }) - return - } - const url = `https://imagedelivery.net/${cloudflareUploadKey}/${cloudflareId}` - await cloudflareUploadComplete({ variables: { id: cloudflareId } }) - prependCloudflareImage( - cache, - { id: cloudflareId, url, blurhash: null }, - false - ) - onUploaded?.() - onChange({ - src: `${url}/public`, - scale: 100, - focalLeft: 50, - focalTop: 50 + let cloudflareResponse + try { + cloudflareResponse = await ( + await fetch(uploadUrl, { + method: 'POST', + body: formData as unknown as FormDataType }) - if (successResetTimerRef.current != null) { - clearTimeout(successResetTimerRef.current) - } - successResetTimerRef.current = setTimeout( - () => setSuccess(undefined), - 4000 - ) - setUploading?.(undefined) - sendImageUploadSuccessEvent({ - fileSize: file.size, - fileType: file.type - }) - } catch { - setSuccess(false) - setUploading?.(false) - sendImageUploadFailureEvent({ - fileSize: file.size, - fileType: file.type, - errorCode: 'upload-exception' - }) - } + ).json() + } catch { + setSuccess(false) + setUploading?.(false) + sendImageUploadFailureEvent({ + fileSize: file.size, + fileType: file.type, + errorCode: 'upload-exception' + }) + return + } + + if (cloudflareResponse.errors?.length) { + const cloudflareError = cloudflareResponse.errors[0].code + setSuccess(false) + setUploading?.(false) + setErrorCode(cloudflareError as ErrorCode) + sendImageUploadFailureEvent({ + fileSize: file.size, + fileType: file.type, + errorCode: String(cloudflareError) + }) + return + } + + if (cloudflareResponse.success !== true) { + setSuccess(false) + setUploading?.(false) + sendImageUploadFailureEvent({ + fileSize: file.size, + fileType: file.type, + errorCode: 'upload-failed' + }) + return + } + + const cloudflareId = cloudflareResponse?.result?.id + const cloudflareUploadKey = process.env.NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY + if ( + cloudflareId == null || + cloudflareUploadKey == null || + cloudflareUploadKey === '' + ) { + setSuccess(false) + setUploading?.(false) + sendImageUploadFailureEvent({ + fileSize: file.size, + fileType: file.type, + errorCode: 'upload-invalid-response' + }) + return + } + + try { + await cloudflareUploadComplete({ variables: { id: cloudflareId } }) + } catch { + setSuccess(false) + setUploading?.(false) + sendImageUploadFailureEvent({ + fileSize: file.size, + fileType: file.type, + errorCode: 'upload-mark-complete-failed' + }) + return + } + + const url = `https://imagedelivery.net/${cloudflareUploadKey}/${cloudflareId}` + prependCloudflareImage( + cache, + { id: cloudflareId, url, blurhash: null }, + false + ) + onUploaded?.() + onChange({ + src: `${url}/public`, + scale: 100, + focalLeft: 50, + focalTop: 50 + }) + setSuccess(true) + if (successResetTimerRef.current != null) { + clearTimeout(successResetTimerRef.current) } + successResetTimerRef.current = setTimeout( + () => setSuccess(undefined), + 4000 + ) + setUploading?.(undefined) + sendImageUploadSuccessEvent({ + fileSize: file.size, + fileType: file.type + }) } const { getRootProps, open, getInputProps, isDragAccept } = useDropzone({ diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx index dad7e0c7152..308417f354b 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx +++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx @@ -7,6 +7,7 @@ import { JourneyProvider } from '@core/journeys/ui/JourneyProvider' import { publishedJourney } from '@core/journeys/ui/TemplateView/data' import { cloudflareUploadMutationMock } from '../../../../../../libs/useCloudflareUploadByFileMutation/useCloudflareUploadByFileMutation.mock' +import { cloudflareUploadCompleteMock } from '../../../../../../libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation.mock' import { journeyImageBlockAssociationUpdateMock } from '../../../../../../libs/useJourneyImageBlockAssociationUpdateMutation/useJourneyImageBlockAssociationUpdateMutation.mock' import { journeyImageBlockCreateMock } from '../../../../../../libs/useJourneyImageBlockCreateMutation/useJourneyImageBlockCreateMutation.mock' import { journeyImageBlockUpdateMock } from '../../../../../../libs/useJourneyImageBlockUpdateMutation/useJourneyImageBlockUpdateMutation.mock' @@ -152,7 +153,7 @@ describe('SocialScreenSocialImage', () => { })) render( - + { render( { render( { + return { + request: { + query: CLOUDFLARE_UPLOAD_COMPLETE, + variables: { id } + }, + result: jest.fn(() => ({ + data: { cloudflareUploadComplete: true } + })) + } +} diff --git a/apps/journeys-admin/src/libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation.ts b/apps/journeys-admin/src/libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation.ts new file mode 100644 index 00000000000..e5ccd009805 --- /dev/null +++ b/apps/journeys-admin/src/libs/useCloudflareUploadCompleteMutation/useCloudflareUploadCompleteMutation.ts @@ -0,0 +1,29 @@ +import { + MutationHookOptions, + MutationTuple, + gql, + useMutation +} from '@apollo/client' + +import { + CloudflareUploadComplete, + CloudflareUploadCompleteVariables +} from '../../../__generated__/CloudflareUploadComplete' + +export const CLOUDFLARE_UPLOAD_COMPLETE = gql` + mutation CloudflareUploadComplete($id: ID!) { + cloudflareUploadComplete(id: $id) + } +` + +export function useCloudflareUploadCompleteMutation( + options?: MutationHookOptions< + CloudflareUploadComplete, + CloudflareUploadCompleteVariables + > +): MutationTuple { + return useMutation( + CLOUDFLARE_UPLOAD_COMPLETE, + options + ) +} diff --git a/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.spec.tsx b/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.spec.tsx index f951087b54a..d273d8382ac 100644 --- a/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.spec.tsx +++ b/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.spec.tsx @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { ErrorCode, useDropzone } from 'react-dropzone' import { useCloudflareUploadByFileMutation } from '../useCloudflareUploadByFileMutation' +import { useCloudflareUploadCompleteMutation } from '../useCloudflareUploadCompleteMutation' import { useImageUpload } from './useImageUpload' @@ -14,11 +15,19 @@ jest.mock('../useCloudflareUploadByFileMutation', () => ({ useCloudflareUploadByFileMutation: jest.fn() })) +jest.mock('../useCloudflareUploadCompleteMutation', () => ({ + useCloudflareUploadCompleteMutation: jest.fn() +})) + const mockUseDropzone = useDropzone as jest.MockedFunction const mockUseCloudflareUploadByFileMutation = useCloudflareUploadByFileMutation as jest.MockedFunction< typeof useCloudflareUploadByFileMutation > +const mockUseCloudflareUploadCompleteMutation = + useCloudflareUploadCompleteMutation as jest.MockedFunction< + typeof useCloudflareUploadCompleteMutation + > describe('useImageUpload', () => { let originalEnv: NodeJS.ProcessEnv @@ -46,6 +55,9 @@ describe('useImageUpload', () => { fileRejections: [] } as any) mockUseCloudflareUploadByFileMutation.mockReturnValue([jest.fn()] as any) + mockUseCloudflareUploadCompleteMutation.mockReturnValue([ + jest.fn().mockResolvedValue({ data: { cloudflareUploadComplete: true } }) + ] as any) }) afterEach(() => { diff --git a/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.ts b/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.ts index 7cf787d7b9c..d92e16e2844 100644 --- a/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.ts +++ b/apps/journeys-admin/src/libs/useImageUpload/useImageUpload.ts @@ -9,6 +9,7 @@ import { } from 'react-dropzone' import { useCloudflareUploadByFileMutation } from '../useCloudflareUploadByFileMutation' +import { useCloudflareUploadCompleteMutation } from '../useCloudflareUploadCompleteMutation' /** * Error codes for image upload. @@ -73,6 +74,7 @@ export function useImageUpload( noKeyboard = false } = useImageUploadOptions const [createCloudflareUploadByFile] = useCloudflareUploadByFileMutation() + const [cloudflareUploadComplete] = useCloudflareUploadCompleteMutation() const [loading, setLoading] = useState(false) const loadingRef = useRef(false) const [success, setSuccess] = useState(undefined) @@ -152,11 +154,23 @@ export function useImageUpload( ).json() if (response.success === true) { - setSuccess(true) + const cloudflareId = response.result.id as string + try { + await cloudflareUploadComplete({ + variables: { id: cloudflareId } + }) + } catch { + setSuccess(false) + const error: ImageUploadErrorCode = 'unknown-error' + setErrorCode(error) + onUploadError?.(error, getErrorMessage(error)) + return + } const src = `https://imagedelivery.net/${ process.env.NEXT_PUBLIC_CLOUDFLARE_UPLOAD_KEY ?? '' - }/${response.result.id as string}/public` + }/${cloudflareId}/public` onUploadComplete(src) + setSuccess(true) if (successTimeoutRef.current != null) { clearTimeout(successTimeoutRef.current) } From c3403bbbe0e642156064a9939e3b32bc519b82be Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 05:03:59 +0000 Subject: [PATCH 3/4] fix: lint issues --- .../src/schema/cloudflare/image/image.spec.ts | 1 - .../CustomImage/ImageUpload/ImageUpload.tsx | 5 +---- .../SocialScreenSocialImage.spec.tsx | 21 ++++++++++++++++--- .../useCloudflareUploadCompleteMutation.ts | 8 +++---- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/apis/api-media/src/schema/cloudflare/image/image.spec.ts b/apis/api-media/src/schema/cloudflare/image/image.spec.ts index 1049083891b..c2af5061bec 100644 --- a/apis/api-media/src/schema/cloudflare/image/image.spec.ts +++ b/apis/api-media/src/schema/cloudflare/image/image.spec.ts @@ -323,7 +323,6 @@ describe('cloudflareImage', () => { orderBy: { createdAt: 'desc' } }) }) - }) describe('getMyCloudflareImage', () => { diff --git a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx index b9f1e1e5fcd..53ab0f12290 100644 --- a/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx +++ b/apps/journeys-admin/src/components/Editor/Slider/Settings/Drawer/ImageBlockEditor/CustomImage/ImageUpload/ImageUpload.tsx @@ -182,10 +182,7 @@ export function ImageUpload({ if (successResetTimerRef.current != null) { clearTimeout(successResetTimerRef.current) } - successResetTimerRef.current = setTimeout( - () => setSuccess(undefined), - 4000 - ) + successResetTimerRef.current = setTimeout(() => setSuccess(undefined), 4000) setUploading?.(undefined) sendImageUploadSuccessEvent({ fileSize: file.size, diff --git a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx index 308417f354b..e70ae50f6b2 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx +++ b/apps/journeys-admin/src/components/TemplateCustomization/MultiStepForm/Screens/SocialScreen/SocialScreenSocialImage/SocialScreenSocialImage.spec.tsx @@ -153,7 +153,13 @@ describe('SocialScreenSocialImage', () => { })) render( - + { render( { render( ): MutationTuple { - return useMutation( - CLOUDFLARE_UPLOAD_COMPLETE, - options - ) + return useMutation< + CloudflareUploadComplete, + CloudflareUploadCompleteVariables + >(CLOUDFLARE_UPLOAD_COMPLETE, options) } From 993fac21f7fe849574b384c85512cb8f549da479 Mon Sep 17 00:00:00 2001 From: edmonday Date: Thu, 21 May 2026 05:06:35 +0000 Subject: [PATCH 4/4] revert: pull getMyCloudflareImages filter out of this PR [NES-1689] Splits BE filter into a separate follow-up ticket. This PR now ships the FE wiring only: every direct-file upload flips uploaded=true going forward, but the picker continues returning all rows (including historical orphans) until the filter ships later. Sequencing avoids the gallery-wipe regression that would happen if the filter shipped before the FE wiring had time to mark existing in-flight uploads complete. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/schema/cloudflare/image/image.spec.ts | 10 +++++----- apis/api-media/src/schema/cloudflare/image/image.ts | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apis/api-media/src/schema/cloudflare/image/image.spec.ts b/apis/api-media/src/schema/cloudflare/image/image.spec.ts index c2af5061bec..b6e3dc907aa 100644 --- a/apis/api-media/src/schema/cloudflare/image/image.spec.ts +++ b/apis/api-media/src/schema/cloudflare/image/image.spec.ts @@ -224,7 +224,7 @@ describe('cloudflareImage', () => { } }) expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith({ - where: { userId: 'testUserId', uploaded: true }, + where: { userId: 'testUserId' }, orderBy: { createdAt: 'desc' } }) }) @@ -281,7 +281,7 @@ describe('cloudflareImage', () => { } }) expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith({ - where: { userId: 'testUserId', uploaded: true }, + where: { userId: 'testUserId' }, orderBy: { createdAt: 'desc' }, take: 10, skip: 0 @@ -295,7 +295,7 @@ describe('cloudflareImage', () => { variables: { isAi: true } }) expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith({ - where: { userId: 'testUserId', uploaded: true, isAi: true }, + where: { userId: 'testUserId', isAi: true }, orderBy: { createdAt: 'desc' } }) }) @@ -307,7 +307,7 @@ describe('cloudflareImage', () => { variables: { isAi: false } }) expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith({ - where: { userId: 'testUserId', uploaded: true, isAi: false }, + where: { userId: 'testUserId', isAi: false }, orderBy: { createdAt: 'desc' } }) }) @@ -319,7 +319,7 @@ describe('cloudflareImage', () => { variables: { isAi: null } }) expect(prismaMock.cloudflareImage.findMany).toHaveBeenCalledWith({ - where: { userId: 'testUserId', uploaded: true }, + where: { userId: 'testUserId' }, orderBy: { createdAt: 'desc' } }) }) diff --git a/apis/api-media/src/schema/cloudflare/image/image.ts b/apis/api-media/src/schema/cloudflare/image/image.ts index 91a8e07e22c..299c3dd5440 100644 --- a/apis/api-media/src/schema/cloudflare/image/image.ts +++ b/apis/api-media/src/schema/cloudflare/image/image.ts @@ -85,7 +85,6 @@ builder.queryFields((t) => ({ ...query, where: { userId: user.id, - uploaded: true, ...(isAi != null ? { isAi } : {}) }, orderBy: { createdAt: 'desc' },