diff --git a/components/video-page/assets-pane.tsx b/components/video-page/assets-pane.tsx index c2b50c0..58545cb 100644 --- a/components/video-page/assets-pane.tsx +++ b/components/video-page/assets-pane.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import * as tus from 'tus-js-client'; import { toast } from 'sonner'; +import { toastApiError } from '@/lib/client/api-error'; import { Download, FileVideo, @@ -56,6 +57,7 @@ function formatTime(seconds: number): string { type UploadAudioResponse = { data?: { url?: string; reservationId?: string | null }; error?: string; + code?: string; }; function getAudioUploadValidationError(file: Blob): string | null { @@ -369,10 +371,11 @@ export const AssetsPane = memo(function AssetsPane({ const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string; reservationId?: string | null }; error?: string; + code?: string; } | null; const uploadedImageUrl = uploadPayload?.data?.url; if (!uploadRes.ok || !uploadedImageUrl) { - toast.error(`${file.name}: ${uploadPayload?.error || 'Failed to upload image'}`); + toastApiError(uploadPayload, 'Failed to upload image', { prefix: file.name }); return false; } @@ -385,7 +388,7 @@ export const AssetsPane = memo(function AssetsPane({ return !!created; } catch (error) { console.error('Failed to upload image asset:', error); - toast.error(`${file.name}: Failed to upload image`); + toastApiError(error, 'Failed to upload image', { prefix: file.name }); return false; } }, @@ -523,10 +526,11 @@ export const AssetsPane = memo(function AssetsPane({ uploadToken: string; }; error?: string; + code?: string; } | null; if (!initRes.ok || !initPayload?.data) { - toast.error(`${file.name}: ${initPayload?.error || 'Failed to initialize Bunny upload'}`); + toastApiError(initPayload, 'Failed to initialize Bunny upload', { prefix: file.name }); return false; } @@ -578,7 +582,7 @@ export const AssetsPane = memo(function AssetsPane({ return true; } catch (error) { console.error('Failed to upload Bunny asset:', error); - toast.error(`${file.name}: Failed to upload Bunny video`); + toastApiError(error, 'Failed to upload Bunny video', { prefix: file.name }); if (uploadedVideoId && uploadToken) { await fetch(`/api/videos/${videoId}/assets/bunny-init`, { method: 'DELETE', @@ -631,7 +635,7 @@ export const AssetsPane = memo(function AssetsPane({ return true; } catch (error) { console.error('Failed to upload R2 asset video:', error); - toast.error(`${file.name}: Failed to upload video`); + toastApiError(error, 'Failed to upload video', { prefix: file.name }); return false; } finally { setIsUploadingBunny(false); @@ -801,12 +805,12 @@ export const AssetsPane = memo(function AssetsPane({ const uploadPayload = await readUploadAudioResponse(uploadRes); const uploadedUrl = uploadPayload?.data?.url; if (!uploadRes.ok || !uploadedUrl) { - toast.error( - `${fileName}: ${ - uploadPayload?.error || - (uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) || - 'Failed to upload voice recording' - }` + toastApiError( + uploadPayload, + uploadRes.status === 413 + ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE + : 'Failed to upload voice recording', + { prefix: fileName } ); return false; } @@ -821,7 +825,7 @@ export const AssetsPane = memo(function AssetsPane({ return !!created; } catch (error) { console.error('Failed to upload voice asset:', error); - toast.error(`${fileName}: Failed to upload voice recording`); + toastApiError(error, 'Failed to upload voice recording', { prefix: fileName }); return false; } }, diff --git a/components/video-page/hooks/use-comment-actions.ts b/components/video-page/hooks/use-comment-actions.ts index 02ce4e4..097e125 100644 --- a/components/video-page/hooks/use-comment-actions.ts +++ b/components/video-page/hooks/use-comment-actions.ts @@ -28,6 +28,7 @@ import { } from '@/components/video-page/image-upload-utils'; import { validateAnnotationStrokes } from '@/lib/validation'; import { withWebmDuration } from '@/lib/webm-duration'; +import { ApiRequestError, apiRequestError, toastApiError } from '@/lib/client/api-error'; interface UseCommentActionsParams extends CommentActionsConfig { setVideo: Dispatch>; @@ -61,17 +62,6 @@ function getAudioUploadFilename(blob: Blob): string { return 'recording.webm'; } -/** - * A step of the submit that failed with something worth reading out. - * - * The attachment goes up before the comment does, so a full account fails on the - * image and never reaches the comment at all. Reporting that as "failed to add - * comment" tells the uploader to try again, which is the one thing that cannot - * work. Carried as its own error type so a network fault, which has no message - * anybody wants to see, still falls back to the generic line. - */ -class CommentSubmitError extends Error {} - export function useCommentActions({ videoId, setVideo, @@ -192,7 +182,7 @@ export function useCommentActions({ } | null; const token = payload?.data?.token; if (!response.ok || !token) { - throw new Error(payload?.error || 'Failed to prepare upload'); + throw apiRequestError(payload, 'Failed to prepare upload'); } return token; }, @@ -274,10 +264,14 @@ export function useCommentActions({ }); if (!imageRes.ok) { + // The attachment goes up before the comment does, so a full account + // fails here and never reaches the comment at all. Thrown with the + // code attached so the catch below can offer the way out. const imagePayload = (await imageRes.json().catch(() => null)) as { error?: string; + code?: string; } | null; - throw new CommentSubmitError(imagePayload?.error || 'Failed to upload image'); + throw apiRequestError(imagePayload, 'Failed to upload image'); } const imageDataResponse = await imageRes.json(); imageData = { url: imageDataResponse.data.url }; @@ -336,8 +330,11 @@ export function useCommentActions({ ), }; }); - const payload = (await res.json().catch(() => null)) as { error?: string } | null; - toast.error(payload?.error || 'Failed to add comment'); + const payload = (await res.json().catch(() => null)) as { + error?: string; + code?: string; + } | null; + toastApiError(payload, 'Failed to add comment'); } } catch (error) { setVideo((prev) => { @@ -351,7 +348,9 @@ export function useCommentActions({ ), }; }); - toast.error(error instanceof CommentSubmitError ? error.message : 'Failed to add comment'); + // A network fault has no message anybody wants to see, so only an + // ApiRequestError speaks for itself; toastApiError falls back for the rest. + toastApiError(error instanceof ApiRequestError ? error : null, 'Failed to add comment'); } finally { setIsSubmittingComment(false); setIsUploadingImage(false); diff --git a/components/video-page/hooks/use-version-actions.ts b/components/video-page/hooks/use-version-actions.ts index f24ee1c..727ab11 100644 --- a/components/video-page/hooks/use-version-actions.ts +++ b/components/video-page/hooks/use-version-actions.ts @@ -12,6 +12,7 @@ import { import type { VersionActionsConfig, VideoData } from '@/components/video-page/types'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload'; +import { apiRequestError, toastApiError } from '@/lib/client/api-error'; /** What a failed version upload has to undo, depending on which provider it started on. */ type PendingVersionCleanup = @@ -113,7 +114,13 @@ export function useVersionActions({ body: JSON.stringify({ title, sizeBytes: file.size.toString() }), }); - if (!initRes.ok) throw new Error('Failed to initialize upload'); + if (!initRes.ok) { + const initPayload = (await initRes.json().catch(() => null)) as { + error?: string; + code?: string; + } | null; + throw apiRequestError(initPayload, 'Failed to initialize upload'); + } const { data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken }, } = await initRes.json(); @@ -236,7 +243,7 @@ export function useVersionActions({ if (!res.ok) { const data = await res.json().catch(() => null); - throw new Error(data?.error || 'Failed to create version'); + throw apiRequestError(data, 'Failed to create version'); } const versionData = await res.json(); @@ -277,7 +284,7 @@ export function useVersionActions({ } } console.error('Failed to create version:', errorObj); - toast.error(errorObj.message || 'Failed to create version'); + toastApiError(errorObj, 'Failed to create version'); } finally { setIsCreatingVersion(false); setNewVersionUploadProgress(0); diff --git a/components/video-page/hooks/use-video-assets.ts b/components/video-page/hooks/use-video-assets.ts index 5442fcc..f5d4317 100644 --- a/components/video-page/hooks/use-video-assets.ts +++ b/components/video-page/hooks/use-video-assets.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import type { VideoAsset } from '@/components/video-page/types'; +import { apiRequestError, toastApiError } from '@/lib/client/api-error'; type BunnyDownloadPreference = 'original' | 'compressed'; @@ -43,6 +44,7 @@ interface AssetsListResponse { interface AssetCreateResponse { data?: VideoAsset; error?: string; + code?: string; } const ASSET_PAGE_SIZE = 40; @@ -185,7 +187,7 @@ export function useVideoAssets({ }); const body = (await res.json().catch(() => null)) as AssetCreateResponse | null; if (!res.ok || !body?.data) { - toast.error(body?.error || 'Failed to create asset'); + toastApiError(body, 'Failed to create asset'); return null; } @@ -288,7 +290,7 @@ export function useVideoAssets({ } | null; const token = payload?.data?.token; if (!response.ok || !token) { - throw new Error(payload?.error || 'Failed to prepare upload'); + throw apiRequestError(payload, 'Failed to prepare upload'); } return token; }, diff --git a/lib/api-response.ts b/lib/api-response.ts index 6afa021..3053cbd 100644 --- a/lib/api-response.ts +++ b/lib/api-response.ts @@ -67,6 +67,12 @@ export const ErrorCode = { // Storage errors STORAGE_LIMIT_EXCEEDED: 'STORAGE_LIMIT_EXCEEDED', + /** + * Out of room because the account has not paid, rather than because the plan + * is full. Its own code so the client can offer the upgrade, which is the + * actual remedy here and is no help at all on the paid ceiling. + */ + TRIAL_STORAGE_LIMIT_EXCEEDED: 'TRIAL_STORAGE_LIMIT_EXCEEDED', } as const; /** @@ -175,4 +181,13 @@ export const apiErrors = { storageExceeded: ( message = 'Storage limit exceeded. Please delete some files to free up space.' ) => errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.STORAGE_LIMIT_EXCEEDED), + + /** + * The same 507, for an account that is out of room because it is on the free + * trial. Telling this caller to delete files is advice that does not apply: + * they have three gigabytes because they have not subscribed, not because they + * have filled two hundred. + */ + trialStorageExceeded: (message: string) => + errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.TRIAL_STORAGE_LIMIT_EXCEEDED), }; diff --git a/lib/client/api-error.ts b/lib/client/api-error.ts new file mode 100644 index 0000000..e659015 --- /dev/null +++ b/lib/client/api-error.ts @@ -0,0 +1,101 @@ +// What a failed API call is worth showing the person who made it. +// +// Every route answers with `{ error, code? }`, and the code is the part that +// says whether there is anything the caller can do. Losing it on the way to a +// toast is how a full trial account ends up reading "Failed to upload" and +// trying the same upload again. + +import { toast } from 'sonner'; + +/** The machine-readable half of an error response. Mirrors ErrorCode in lib/api-response.ts. */ +export const API_ERROR_CODES = { + /** Out of room because the account has not subscribed, not because the plan is full. */ + TRIAL_STORAGE_LIMIT_EXCEEDED: 'TRIAL_STORAGE_LIMIT_EXCEEDED', +} as const; + +export interface ApiErrorPayload { + error?: string; + code?: string; +} + +/** + * A failure that came back from our own API, with the code still attached. + * + * The upload helpers throw rather than return, so without this the code is gone + * by the time anything is in a position to show it. + */ +export class ApiRequestError extends Error { + readonly code: string | null; + + constructor(message: string, code?: string | null) { + super(message); + this.name = 'ApiRequestError'; + this.code = code ?? null; + } +} + +/** Builds the error to throw from a parsed `{ error, code }` body. */ +export function apiRequestError( + payload: ApiErrorPayload | null, + fallback: string +): ApiRequestError { + return new ApiRequestError(payload?.error || fallback, payload?.code); +} + +function codeOf(source: unknown): string | null { + if (source instanceof ApiRequestError) return source.code; + if (source && typeof source === 'object' && 'code' in source) { + const code = (source as ApiErrorPayload).code; + return typeof code === 'string' ? code : null; + } + return null; +} + +function messageOf(source: unknown, fallback: string): string { + if (source instanceof Error) return source.message || fallback; + if (source && typeof source === 'object' && 'error' in source) { + const message = (source as ApiErrorPayload).error; + if (typeof message === 'string' && message) return message; + } + return fallback; +} + +export interface ToastApiErrorOptions { + /** Prepended as `${prefix}: ${message}`, for per-file failures in a batch. */ + prefix?: string; +} + +/** + * Shows an API failure, with a way out attached when there is one. + * + * Takes either a parsed response body or a thrown error, so the same call works + * whether the caller checked `res.ok` itself or caught what an upload helper + * threw. On the trial storage ceiling it adds a link to the billing settings, + * because subscribing is the only thing that makes the upload possible and the + * uploader has no way to know that from "storage limit exceeded". + */ +export function toastApiError( + source: unknown, + fallback: string, + options: ToastApiErrorOptions = {} +): void { + const message = messageOf(source, fallback); + const text = options.prefix ? `${options.prefix}: ${message}` : message; + + if (codeOf(source) === API_ERROR_CODES.TRIAL_STORAGE_LIMIT_EXCEEDED) { + toast.error(text, { + // Longer than a plain error: this one is asking for a decision rather than + // just reporting, and it disappears under the cursor at the usual timing. + duration: 12000, + action: { + label: 'See plans', + onClick: () => { + window.location.href = '/settings'; + }, + }, + }); + return; + } + + toast.error(text); +} diff --git a/lib/client/project-video-upload.ts b/lib/client/project-video-upload.ts index bc93ba7..ad6939a 100644 --- a/lib/client/project-video-upload.ts +++ b/lib/client/project-video-upload.ts @@ -3,6 +3,7 @@ import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload'; import type { DirectUploadProvider } from '@/components/video-page/types'; +import { apiRequestError } from '@/lib/client/api-error'; export const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv']; @@ -143,7 +144,7 @@ export async function uploadProjectVideo( } | null; if (!createResponse.ok) { - throw new Error(createPayload?.error || 'Failed to create video'); + throw apiRequestError(createPayload, 'Failed to create video'); } pendingCleanup = null; @@ -171,7 +172,7 @@ export async function uploadProjectVideo( } | null; if (!initResponse.ok || !initPayload?.data) { - throw new Error(initPayload?.error || 'Failed to initialize upload'); + throw apiRequestError(initPayload, 'Failed to initialize upload'); } const { videoId, libraryId, signature, expirationTime, uploadToken } = initPayload.data; @@ -243,7 +244,7 @@ export async function uploadProjectVideo( } | null; if (!createResponse.ok) { - throw new Error(createPayload?.error || 'Failed to create video'); + throw apiRequestError(createPayload, 'Failed to create video'); } pendingCleanup = null; diff --git a/lib/client/r2-asset-video-upload.ts b/lib/client/r2-asset-video-upload.ts index de1a87f..1db211e 100644 --- a/lib/client/r2-asset-video-upload.ts +++ b/lib/client/r2-asset-video-upload.ts @@ -1,4 +1,5 @@ import { captureVideoThumbnail } from '@/lib/client/video-thumbnail'; +import { apiRequestError } from '@/lib/client/api-error'; import { uploadBytesWithProgress, type UploadProgressHandler } from '@/lib/client/r2-video-upload'; export type R2AssetVideoInitResponse = { @@ -39,7 +40,7 @@ export async function initR2AssetVideoUpload( error?: string; } | null; if (!initRes.ok || !initPayload?.data) { - throw new Error(initPayload?.error || 'Failed to initialize video upload'); + throw apiRequestError(initPayload, 'Failed to initialize video upload'); } return initPayload.data; diff --git a/lib/client/r2-video-upload.ts b/lib/client/r2-video-upload.ts index c82ba2f..c91e5e5 100644 --- a/lib/client/r2-video-upload.ts +++ b/lib/client/r2-video-upload.ts @@ -1,4 +1,5 @@ import { captureVideoThumbnail } from '@/lib/client/video-thumbnail'; +import { apiRequestError } from '@/lib/client/api-error'; import { getMultipartProgressPercent, getPartByteRange, @@ -151,7 +152,7 @@ async function completeMultipartUpload( if (!res.ok) { const payload = (await res.json().catch(() => null)) as { error?: string } | null; - throw new Error(payload?.error || 'Failed to complete multipart upload'); + throw apiRequestError(payload, 'Failed to complete multipart upload'); } } @@ -243,7 +244,7 @@ export async function initR2VideoUpload( error?: string; } | null; if (!initRes.ok || !initPayload?.data) { - throw new Error(initPayload?.error || 'Failed to initialize video upload'); + throw apiRequestError(initPayload, 'Failed to initialize video upload'); } return initPayload.data; diff --git a/tests/api/bunny-upload-reservation.test.ts b/tests/api/bunny-upload-reservation.test.ts index 6d36bc8..ce6811d 100644 --- a/tests/api/bunny-upload-reservation.test.ts +++ b/tests/api/bunny-upload-reservation.test.ts @@ -16,9 +16,15 @@ import { } from '@/app/api/projects/[projectId]/videos/bunny-init/route'; import { POST as createAsset } from '@/app/api/videos/[videoId]/assets/route'; import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota'; +import { PLAN_STORAGE_LIMIT_BYTES } from '@/lib/storage-quota'; import { apiRequest, callRoute, readData, readError } from '../helpers/request'; import { signedInAs } from '../helpers/session'; -import { createVideo, seedProject } from '../factories'; +import { + createSubscribedUser, + createUploadReservation, + createVideo, + seedProject, +} from '../factories'; const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024); @@ -261,3 +267,65 @@ describe('a Bunny hold cannot be consumed by another flow', () => { expect(second.status).toBe(507); }); }); + +// What a refusal says, and to whom. +// +// A trial account is out of room because it has not subscribed, so "delete some +// files" is advice that does not apply and the upgrade is the only way through. +// The two cases carry different codes because the client offers a link on one of +// them and must not on the other. +describe('what the storage refusal says', () => { + it('names the trial ceiling and its own code for an unpaid account', async () => { + const scenario = await seedProject(); + signedInAs(scenario.owner); + + const response = await initUpload(scenario.project.id, BigInt(4) * GIB); + + expect(response.status).toBe(507); + const body = (await response.json()) as { error: string; code: string }; + expect(body.code).toBe('TRIAL_STORAGE_LIMIT_EXCEEDED'); + expect(body.error).toContain('3 GB'); + expect(body.error).toContain('Upgrade'); + }); + + it('tells a paying account to free up space instead', async () => { + const scenario = await seedProject({ ownerUser: await createSubscribedUser() }); + signedInAs(scenario.owner); + + // Full at the paid ceiling rather than the trial one. + await createUploadReservation({ + billedUserId: scenario.owner.id, + sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024), + }); + + const response = await initUpload(scenario.project.id, BigInt(1) * GIB); + + expect(response.status).toBe(507); + const body = (await response.json()) as { error: string; code: string }; + expect(body.code).toBe('STORAGE_LIMIT_EXCEEDED'); + expect(body.error).toContain('delete some files'); + }); +}); + +// Asked directly: five one-gigabyte uploads started at once against a three +// gigabyte trial. Whether they go up in one piece or in parts makes no +// difference, because r2-init takes the reservation before it decides on +// multipart, and bunny-init holds one for the size the client declared. +describe('several uploads started at once', () => { + it('grants only the ones that fit and refuses the rest', async () => { + const scenario = await seedProject(); + signedInAs(scenario.owner); + + const results = await Promise.all( + Array.from({ length: 5 }, () => initUpload(scenario.project.id, BigInt(1) * GIB)) + ); + + const granted = results.filter((response) => response.status === 200); + const refused = results.filter((response) => response.status === 507); + + // Two fit: the check is >=, so the third would land exactly on 3 GiB. + expect(granted).toHaveLength(2); + expect(refused).toHaveLength(3); + expect(await db.uploadReservation.count()).toBe(2); + }); +});