diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 6249bf8..9a568e1 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -77,6 +77,8 @@ interface StorageInfo { usedBytes: string; limitBytes: string; percentage: number; + /** False on the free trial, where the way out is subscribing rather than deleting. */ + isPaid: boolean; } function formatBytes(bytesStr: string): string { @@ -501,7 +503,8 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo Storage - Combined usage across video files and media attachments (200 GB limit) + Combined usage across video files and media attachments + {storageInfo ? ` (${formatBytes(storageInfo.limitBytes)} limit)` : ''} @@ -541,11 +544,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo : '' } /> - {storageInfo.percentage >= 90 && ( -

- Storage is almost full. Delete unused files or contact support. -

- )} + {storageInfo.percentage >= 90 && + (storageInfo.isPaid ? ( +

+ Storage is almost full. Delete unused files or contact support. +

+ ) : ( +

+ Your free trial storage is almost full. Subscribe above for more room, or + delete unused files. +

+ ))} )}
diff --git a/app/api/settings/storage/route.ts b/app/api/settings/storage/route.ts index c057a27..2edbed6 100644 --- a/app/api/settings/storage/route.ts +++ b/app/api/settings/storage/route.ts @@ -1,6 +1,6 @@ import { auth } from '@/lib/auth'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; -import { getUserStorageInfo } from '@/lib/storage-quota'; +import { getStorageContextForUser, getUserStorageInfo } from '@/lib/storage-quota'; import { hasBillingAccess } from '@/lib/billing'; import { db } from '@/lib/db'; @@ -27,12 +27,19 @@ export async function GET() { return apiErrors.forbidden(); } - const info = await getUserStorageInfo(session.user.id); + const [info, storage] = await Promise.all([ + getUserStorageInfo(session.user.id), + getStorageContextForUser(session.user.id), + ]); const response = successResponse({ usedBytes: info.usedBytes.toString(), limitBytes: info.limitBytes.toString(), percentage: info.percentage, + // Which ceiling this is, so the card can name it and say what to do about it. + // A trial has 3 GB because it has not subscribed; deleting files is the wrong + // advice there, and "200 GB limit" was the wrong caption. + isPaid: storage.isPaid, }); // Cache for 60s — stale data is acceptable for a usage meter diff --git a/app/api/videos/[videoId]/assets/route.ts b/app/api/videos/[videoId]/assets/route.ts index 4573b54..956c5fb 100644 --- a/app/api/videos/[videoId]/assets/route.ts +++ b/app/api/videos/[videoId]/assets/route.ts @@ -32,11 +32,13 @@ import { enforceStorageQuota, reserveStorageQuota, releaseStorageReservation, - getStorageLimitForUser, + getStorageContextForUser, + storageExceededResponse, UPLOAD_RESERVATION_PURPOSES, + type StorageContext, type UploadReservationPurpose, } from '@/lib/storage-quota'; -import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; +import { getUserBunnyStorageBytes } from '@/lib/admin-stats'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; // Sentinel thrown inside a Prisma transaction when a fake reservationId is @@ -307,6 +309,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { // was still in flight. let reservationPurpose: UploadReservationPurpose | null = null; let reservationBilledUserId: string | null = null; + // Carried out of the try so the quota refusal in the catch can be worded for + // the account it is refusing, rather than telling a trial to delete files. + let storageForRefusal: StorageContext | null = null; let finalizedR2AssetSession: { sessionId: string; reservationId: string | null; @@ -582,14 +587,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) { // quota check below, Bunny included. Leaving Bunny out read its own storage as // zero, and on an account whose storage is all Bunny that made the fallback a // check that could not fail. - const preFetchedBunnyData = - provider === VideoAssetProvider.YOUTUBE ? null : await getCachedUserBunnyStorage(); + const preFetchedBunnyBytes = + provider === VideoAssetProvider.YOUTUBE ? null : await getUserBunnyStorageBytes(billedUserId); // The ceiling this account is actually held to, read for the fallback below. // It used to compare against the plan limit, which is 200 GiB whoever is // asking: a caller who quoted a reservation id that no longer existed was // measured against the paid ceiling even on a trial worth 3 GiB. - const storageLimitBytes = await getStorageLimitForUser(billedUserId); + const storage = await getStorageContextForUser(billedUserId); + storageForRefusal = storage; // Create the VideoAsset and atomically consume the upload reservation (if any) // so the spot is never double-counted. @@ -631,12 +637,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) { WHERE "billedUserId" = ${billedUserId} AND "expiresAt" > NOW() `; - const bunnyData = preFetchedBunnyData ?? {}; const totalUsed = (r2Row?.total ?? BigInt(0)) + (resRow?.total ?? BigInt(0)) + - BigInt(bunnyData[billedUserId] ?? 0); - if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storageLimitBytes) { + BigInt(preFetchedBunnyBytes ?? 0); + if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storage.limitBytes) { throw new QuotaExceededInTxError(); } } @@ -712,7 +717,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return withCacheControl(response, 'private, no-store'); } catch (error) { if (error instanceof QuotaExceededInTxError) { - return apiErrors.storageExceeded() as NextResponse; + return storageForRefusal + ? storageExceededResponse(storageForRefusal) + : (apiErrors.storageExceeded() as NextResponse); } await releaseStorageReservation( reservationId, 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/admin-stats.ts b/lib/admin-stats.ts index 39955f0..8c57937 100644 --- a/lib/admin-stats.ts +++ b/lib/admin-stats.ts @@ -221,12 +221,97 @@ export const getCachedBunnyStorageStats = unstable_cache( { revalidate: STORAGE_CACHE_SECONDS } ); +/** + * What this video costs us, as the larger of the two numbers we have. + * + * Bunny reports nothing for a video until it starts encoding, and what it reports + * while encoding is partial: `storageSize` counts what has been written so far and + * climbs as each rendition lands. A six minute cut uploaded at 2.5 GB read as + * 475 MB midway through and settled above 3 GB once it finished, because Bunny + * keeps the original alongside every rendition it makes. + * + * Both halves of the rule follow from that. Taking Bunny's figure whenever it is + * non-zero would hand back most of the quota in the middle of an encode, which is + * the hole the declared size exists to close. Taking the declared size forever + * would ignore the renditions, which are the actual bill and end up larger than + * the source. The larger of the two is right at every point: the declared size + * covers the encode, and Bunny's own number takes over the moment it passes it. + */ +function chargeableSize(reported: number, declared: bigint | null): number { + const declaredBytes = declared === null ? 0 : Number(declared); + return reported > declaredBytes ? reported : declaredBytes; +} + +/** + * Bunny's reported sizes, or an empty map when the call to Bunny failed. + * + * A failed stats call is not a reason to bill an account for nothing. Bunny's own + * figure is unavailable; the sizes declared at upload are sitting in our database + * either way, and reading the whole account as empty is how a full account gets + * waved through. Used to be an early return that skipped the rows entirely. + */ +function reportedSizes(stats: BunnyStorageStats): Record { + return stats.totalBytes < 0 ? {} : stats.byVideoId; +} + +/** + * What one account's Bunny videos cost, read fresh. + * + * This deliberately does not come from the cached per-user map. The declared size + * lands on the row at the moment an upload finalizes, and a map computed up to two + * minutes earlier does not have that row in it. For those two minutes the + * reservation is already gone and the row is not yet visible, so an upload that + * just succeeded reads as zero: the uploader watches their usage fall back to + * nothing, and the next upload is measured against a total that ignores the one + * before it. + * + * The call to Bunny stays cached. It is the slow half and its answer is the same + * for everybody. Only the join against our own rows has to be current. + */ +export async function getUserBunnyStorageBytes(userId: string): Promise { + try { + const [bunnyStats, bunnyVersions, bunnyAssets] = await Promise.all([ + getCachedBunnyStorageStats(), + db.videoVersion.findMany({ + where: { + providerId: 'bunny', + // The workspace owner, not the project owner: this feeds + // getUserTotalStorageBytes, which bills every other provider the same way. + video: { project: { workspace: { ownerId: userId } } }, + }, + select: { videoId: true, sizeBytes: true }, + }), + db.videoAsset.findMany({ + where: { provider: 'BUNNY', providerVideoId: { not: null }, billedUserId: userId }, + select: { providerVideoId: true, sizeBytes: true }, + }), + ]); + + const reported = reportedSizes(bunnyStats); + const seenVideoIds = new Set(); + let total = 0; + + for (const row of [ + ...bunnyVersions.map((v) => ({ videoId: v.videoId, sizeBytes: v.sizeBytes })), + ...bunnyAssets.map((a) => ({ videoId: a.providerVideoId!, sizeBytes: a.sizeBytes })), + ]) { + if (!row.videoId || seenVideoIds.has(row.videoId)) continue; + seenVideoIds.add(row.videoId); + total += chargeableSize(reported[row.videoId] || 0, row.sizeBytes); + } + + return total; + } catch (err) { + logError('Failed to calculate Bunny storage for user:', err); + return 0; + } +} + export const getCachedUserBunnyStorage = unstable_cache( async () => { const perUserStorage: Record = {}; try { const bunnyStats = await getCachedBunnyStorageStats(); - if (bunnyStats.totalBytes < 0) return perUserStorage; const [bunnyVersions, bunnyAssets] = await Promise.all([ db.videoVersion.findMany({ @@ -262,23 +347,7 @@ export const getCachedUserBunnyStorage = unstable_cache( }), ]); - /** - * What this video costs us, as the larger of the two numbers we have. - * - * Bunny reports nothing for a video until it has finished encoding it, - * which on a half-hour source is most of an hour, and reading that zero - * literally meant an upload was free for as long as it was being - * processed: it did not show on the uploader's storage page and it did not - * count against the next upload's quota check. The size declared when the - * upload was admitted stands in until Bunny has a figure of its own, and - * Bunny's wins once it arrives, because the renditions it makes are the - * real bill and they are larger than the source. - */ - const chargeableSize = (reported: number, declared: bigint | null): number => { - const declaredBytes = declared === null ? 0 : Number(declared); - return reported > declaredBytes ? reported : declaredBytes; - }; - + const reported = reportedSizes(bunnyStats); const seenVideoIds = new Set(); for (const version of bunnyVersions) { const ownerId = version.video.project.workspace.ownerId; @@ -286,7 +355,7 @@ export const getCachedUserBunnyStorage = unstable_cache( if (seenVideoIds.has(dedupeKey)) continue; seenVideoIds.add(dedupeKey); - const size = chargeableSize(bunnyStats.byVideoId[version.videoId] || 0, version.sizeBytes); + const size = chargeableSize(reported[version.videoId] || 0, version.sizeBytes); perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size; } @@ -297,10 +366,7 @@ export const getCachedUserBunnyStorage = unstable_cache( if (seenVideoIds.has(dedupeKey)) continue; seenVideoIds.add(dedupeKey); - const size = chargeableSize( - bunnyStats.byVideoId[asset.providerVideoId] || 0, - asset.sizeBytes - ); + const size = chargeableSize(reported[asset.providerVideoId] || 0, asset.sizeBytes); perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size; } } catch (err) { 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/lib/storage-quota.ts b/lib/storage-quota.ts index c01d82b..fcdf088 100644 --- a/lib/storage-quota.ts +++ b/lib/storage-quota.ts @@ -2,7 +2,7 @@ import type { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { apiErrors } from '@/lib/api-response'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; -import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; +import { getUserBunnyStorageBytes } from '@/lib/admin-stats'; import { isPaidTier } from '@/lib/billing'; import { getStorageLimitBytes } from '@/lib/trial-limits'; @@ -18,12 +18,59 @@ export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024 * forget to pass it. */ export async function getStorageLimitForUser(userId: string): Promise { + return (await getStorageContextForUser(userId)).limitBytes; +} + +export interface StorageContext { + /** The ceiling this account is held to. */ + limitBytes: bigint; + /** Whether that ceiling is the plan's or the trial's. */ + isPaid: boolean; +} + +/** + * The ceiling and the reason for it, read together. + * + * The two travel as a pair because a refusal has to say which one it is: an + * unpaid account is out of room because it has not subscribed, and telling it to + * delete files is advice that does not apply. + */ +export async function getStorageContextForUser(userId: string): Promise { const user = await db.user.findUnique({ where: { id: userId }, select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true }, }); - return getStorageLimitBytes(user ? isPaidTier(user) : false, PLAN_STORAGE_LIMIT_BYTES); + const isPaid = user ? isPaidTier(user) : false; + return { limitBytes: getStorageLimitBytes(isPaid, PLAN_STORAGE_LIMIT_BYTES), isPaid }; +} + +/** + * Whole gigabytes where the number is whole, which every ceiling we ship is. + * A host that sets an odd one gets a decimal rather than a rounded lie. + */ +function formatStorageLimit(bytes: bigint): string { + const gigabytes = Number(bytes) / 1024 ** 3; + return `${Number.isInteger(gigabytes) ? gigabytes : gigabytes.toFixed(1)} GB`; +} + +/** + * The refusal, in the words that fit the account it is being given to. + * + * A paying account that has filled 200 GB has to delete something. An unpaid one + * has three gigabytes because it has not subscribed, so the way out is the + * subscription, and the response says so under its own error code rather than + * leaving the client to guess from the number. + */ +export function storageExceededResponse(context: StorageContext): NextResponse { + if (context.isPaid) { + return apiErrors.storageExceeded() as NextResponse; + } + + return apiErrors.trialStorageExceeded( + `Your free trial includes ${formatStorageLimit(context.limitBytes)} of storage. ` + + `Upgrade to get ${formatStorageLimit(PLAN_STORAGE_LIMIT_BYTES)}.` + ) as NextResponse; } // TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads @@ -66,7 +113,7 @@ class QuotaExceededError extends Error {} * every upload. */ export async function getUserTotalStorageBytes(userId: string): Promise { - const [r2AssetRows, r2VideoRows, bunnyByUser, reservationRows] = await Promise.all([ + const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([ db.$queryRaw<[{ total: bigint }]>` SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total FROM video_assets @@ -82,7 +129,7 @@ export async function getUserTotalStorageBytes(userId: string): Promise WHERE w."ownerId" = ${userId} AND vv."providerId" = 'r2' `, - getCachedUserBunnyStorage(), + getUserBunnyStorageBytes(userId), db.$queryRaw<[{ total: bigint }]>` SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total FROM upload_reservations @@ -93,7 +140,7 @@ export async function getUserTotalStorageBytes(userId: string): Promise const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0); const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0); - const bunnyBytes = BigInt(bunnyByUser[userId] ?? 0); + const bunnyBytes = BigInt(bunnyUserBytes); const reservedBytes = reservationRows[0]?.total ?? BigInt(0); return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes; @@ -136,13 +183,13 @@ export async function enforceStorageQuota( return null; } - const [usedBytes, limitBytes] = await Promise.all([ + const [usedBytes, storage] = await Promise.all([ getUserTotalStorageBytes(userId), - getStorageLimitForUser(userId), + getStorageContextForUser(userId), ]); - if (usedBytes + incomingSizeBytes >= limitBytes) { - return apiErrors.storageExceeded() as NextResponse; + if (usedBytes + incomingSizeBytes >= storage.limitBytes) { + return storageExceededResponse(storage); } return null; @@ -176,11 +223,11 @@ export async function reserveStorageQuota( // Fetch Bunny storage and the account's ceiling BEFORE entering the transaction, // to avoid holding the advisory lock during a potentially slow/failing HTTP call // on cache miss or an extra round trip to Postgres. - const [bunnyData, limitBytes] = await Promise.all([ - getCachedUserBunnyStorage(), - getStorageLimitForUser(userId), + const [bunnyUserBytes, storage] = await Promise.all([ + getUserBunnyStorageBytes(userId), + getStorageContextForUser(userId), ]); - const bunnyBytes = BigInt(bunnyData[userId] ?? 0); + const bunnyBytes = BigInt(bunnyUserBytes); try { const reservationId = await db.$transaction(async (tx) => { @@ -222,7 +269,7 @@ export async function reserveStorageQuota( const reservedBytes = resRow?.total ?? BigInt(0); const totalUsed = r2Bytes + reservedBytes + bunnyBytes; - if (totalUsed + incomingSizeBytes >= limitBytes) { + if (totalUsed + incomingSizeBytes >= storage.limitBytes) { throw new QuotaExceededError(); } @@ -237,7 +284,7 @@ export async function reserveStorageQuota( return { reservationId }; } catch (e) { if (e instanceof QuotaExceededError) { - return { error: apiErrors.storageExceeded() as NextResponse }; + return { error: storageExceededResponse(storage) }; } throw e; } 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); + }); +}); diff --git a/tests/api/lib-admin-stats.test.ts b/tests/api/lib-admin-stats.test.ts index fbb8df8..c05bf3d 100644 --- a/tests/api/lib-admin-stats.test.ts +++ b/tests/api/lib-admin-stats.test.ts @@ -36,6 +36,7 @@ import { getCachedUserBunnyStorage, getCachedUserDownloadEgress, getCachedUserMediaStorage, + getUserBunnyStorageBytes, refreshR2StorageSnapshot, } from '@/lib/admin-stats'; import { @@ -603,21 +604,23 @@ describe('getCachedUserBunnyStorage', () => { }); // The -1 sentinel means "Bunny did not answer", and the module must not turn - // that into "this user stores nothing", because lib/storage-quota.ts would - // then hand out headroom the user does not have. An empty map at least leaves - // the R2 figures intact. - it('answers an empty map when the Bunny library could not be read', async () => { + // that into "this user stores nothing", because lib/storage-quota.ts would then + // hand out headroom the user does not have. Bunny's figure is gone; the size + // declared when the upload was admitted is still in our own rows, so that is + // what the account is charged until Bunny can be reached again. + it('falls back to the declared sizes when the Bunny library could not be read', async () => { const scenario = await seedProject(); const video = await createVideo({ projectId: scenario.project.id }); await createVersion({ videoParentId: video.id, providerId: 'bunny', providerVideoId: 'bunny-first', + sizeBytes: BigInt(2048), }); bunnyCredentials(); stubBunnyPages([{ status: 503, body: {} }]); - expect(await getCachedUserBunnyStorage()).toEqual({}); + expect(await getCachedUserBunnyStorage()).toEqual({ [scenario.owner.id]: 2048 }); }); it('is empty on a database with no videos at all', async () => { @@ -987,3 +990,126 @@ describe('getCachedStripeStats', () => { expect(await getCachedStripeStats()).toBeNull(); }); }); + +// The per-user figure the quota checks read, which deliberately does not come +// from the cached map above. +// +// The bug this exists for: the declared size lands on the row at the moment an +// upload finalizes and the reservation is deleted in the same transaction. A map +// computed up to two minutes earlier does not have that row in it, so for those +// two minutes the upload that just succeeded counted as nothing. The uploader +// watched their usage fall back to zero and the next upload was measured against +// a total that ignored the one before it. +describe('getUserBunnyStorageBytes', () => { + it('counts a finished upload straight away, without waiting for Bunny', async () => { + const scenario = await seedProject(); + const video = await createVideo({ projectId: scenario.project.id }); + // Bunny has the video but reports nothing for it yet, which is what an + // encode in progress looks like. + stubBunnyLibrary({ 'bunny-encoding': 0 }); + await createVersion({ + videoParentId: video.id, + providerId: 'bunny', + providerVideoId: 'bunny-encoding', + sizeBytes: BigInt(2_500_000_000), + }); + + expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(2_500_000_000); + }); + + // What Bunny reports mid-encode is partial: it counts what has been written so + // far and climbs as each rendition lands. A 2.5 GB source read as 475 MB + // halfway through and settled above 3 GB once it finished. Letting the partial + // figure displace the declared size would hand most of the quota back in the + // middle of an encode, which is exactly what the declared size is there to stop. + it('keeps the declared size while Bunny figure is still climbing', async () => { + const scenario = await seedProject(); + const video = await createVideo({ projectId: scenario.project.id }); + await createVersion({ + videoParentId: video.id, + providerId: 'bunny', + providerVideoId: 'bunny-encoding', + sizeBytes: BigInt(2_500_000_000), + }); + stubBunnyLibrary({ 'bunny-encoding': 474_900_000 }); + + expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(2_500_000_000); + }); + + // And gets out of the way once the renditions are all there, because they are + // the actual bill and they add up to more than the source. + it('takes Bunny own figure once it passes the declared size', async () => { + const scenario = await seedProject(); + const video = await createVideo({ projectId: scenario.project.id }); + await createVersion({ + videoParentId: video.id, + providerId: 'bunny', + providerVideoId: 'bunny-encoded', + sizeBytes: BigInt(2_500_000_000), + }); + stubBunnyLibrary({ 'bunny-encoded': 3_600_000_000 }); + + expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(3_600_000_000); + }); + + it('counts only the videos billed to the user asked about', async () => { + const mine = await seedProject(); + const theirs = await seedProject(); + const myVideo = await createVideo({ projectId: mine.project.id }); + const theirVideo = await createVideo({ projectId: theirs.project.id }); + await createVersion({ + videoParentId: myVideo.id, + providerId: 'bunny', + providerVideoId: 'bunny-mine', + sizeBytes: BigInt(700), + }); + await createVersion({ + videoParentId: theirVideo.id, + providerId: 'bunny', + providerVideoId: 'bunny-theirs', + sizeBytes: BigInt(900), + }); + stubBunnyLibrary({ 'bunny-mine': 0, 'bunny-theirs': 0 }); + + expect(await getUserBunnyStorageBytes(mine.owner.id)).toBe(700); + expect(await getUserBunnyStorageBytes(theirs.owner.id)).toBe(900); + }); + + it('adds Bunny assets to the user they are billed to, deduped against versions', async () => { + const scenario = await seedProject(); + const video = await createVideo({ projectId: scenario.project.id }); + await createVersion({ + videoParentId: video.id, + providerId: 'bunny', + providerVideoId: 'bunny-shared', + sizeBytes: BigInt(500), + }); + await createVideoAsset({ + videoId: video.id, + billedUserId: scenario.owner.id, + kind: VideoAssetKind.VIDEO, + provider: VideoAssetProvider.BUNNY, + providerVideoId: 'bunny-shared', + sizeBytes: BigInt(500), + }); + await createVideoAsset({ + videoId: video.id, + billedUserId: scenario.owner.id, + kind: VideoAssetKind.VIDEO, + provider: VideoAssetProvider.BUNNY, + providerVideoId: 'bunny-asset-only', + sizeBytes: BigInt(300), + }); + stubBunnyLibrary({ 'bunny-shared': 0, 'bunny-asset-only': 0 }); + + // The shared id is one video however many rows point at it. + expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(800); + }); + + it('is zero for a user with no Bunny videos', async () => { + const scenario = await seedProject(); + stubBunnyLibrary({ 'bunny-someone-else': 999 }); + + expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(0); + }); +}); diff --git a/tests/api/storage-quota.test.ts b/tests/api/storage-quota.test.ts index 916f24d..b41c2aa 100644 --- a/tests/api/storage-quota.test.ts +++ b/tests/api/storage-quota.test.ts @@ -12,7 +12,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/lib/db'; -import { getCachedUserBunnyStorage } from '@/lib/admin-stats'; +import { getUserBunnyStorageBytes } from '@/lib/admin-stats'; import { PLAN_STORAGE_LIMIT_BYTES, UPLOAD_RESERVATION_PURPOSES, @@ -41,7 +41,7 @@ import { const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024); function bunnyStorage(map: Record): void { - vi.mocked(getCachedUserBunnyStorage).mockResolvedValue(map); + vi.mocked(getUserBunnyStorageBytes).mockImplementation(async (userId) => map[userId] ?? 0); } // The mock implementation is module state, so it survives afterEach. Reset it so diff --git a/tests/setup/api.ts b/tests/setup/api.ts index adcff34..c91582e 100644 --- a/tests/setup/api.ts +++ b/tests/setup/api.ts @@ -146,14 +146,15 @@ vi.mock('@/lib/stripe', async (importOriginal) => { // --------------------------------------------------------------------------- // Bunny storage stats // --------------------------------------------------------------------------- -// getCachedUserBunnyStorage() is an HTTP call to the Bunny API, and it sits in -// the middle of reserveStorageQuota(). Default to "no Bunny bytes"; the quota -// suite overrides it to prove Bunny usage counts against the limit. +// Both of these reach the Bunny API, and getUserBunnyStorageBytes() sits in the +// middle of reserveStorageQuota(). Default to "no Bunny bytes"; the quota suite +// overrides them to prove Bunny usage counts against the limit. vi.mock('@/lib/admin-stats', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getCachedUserBunnyStorage: vi.fn(async () => ({}) as Record), + getUserBunnyStorageBytes: vi.fn(async () => 0), }; });