feat(storage): point a full trial account at the upgrade rather than at nothing

Being told "storage limit exceeded" when the limit is the free trial's three
gigabytes is a dead end. The account is not full because it stores a lot; it is
capped because it has not subscribed, and deleting files buys back very little.

The refusal now says which ceiling it is and carries its own error code, so the
toast can offer a link to the billing settings on the trial ceiling and stay
quiet on the paid one, where subscribing changes nothing.

The code had to survive the trip to the toast, which meant the upload helpers
throwing something that carries it rather than a bare Error. Two places were
dropping the server's message on the floor entirely: adding a version reported
"Failed to initialize upload" whatever the server said, and every asset upload
in the pane rewrote its own failure text.
This commit is contained in:
2026-08-18 11:42:30 +03:00
parent 63288761ed
commit 6561e0817e
10 changed files with 239 additions and 40 deletions
+16 -12
View File
@@ -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;
}
},
@@ -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<SetStateAction<VideoData | null>>;
@@ -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);
@@ -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);
@@ -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;
},
+15
View File
@@ -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),
};
+101
View File
@@ -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);
}
+4 -3
View File
@@ -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;
+2 -1
View File
@@ -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;
+3 -2
View File
@@ -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;
+69 -1
View File
@@ -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);
});
});