mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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.
194 lines
5.8 KiB
TypeScript
194 lines
5.8 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { bigIntReplacer } from '@/lib/json-serialize';
|
|
|
|
/**
|
|
* Standardized API error response format
|
|
* All API routes should use this format for consistency
|
|
*/
|
|
export interface ApiErrorResponse {
|
|
error: string;
|
|
code?: string;
|
|
details?: Record<string, string[]>;
|
|
}
|
|
|
|
/**
|
|
* Standardized API success response format
|
|
*/
|
|
export interface ApiSuccessResponse<T = unknown> {
|
|
data: T;
|
|
meta?: {
|
|
page?: number;
|
|
limit?: number;
|
|
total?: number;
|
|
totalPages?: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* HTTP status codes used in the API
|
|
*/
|
|
export const HttpStatus = {
|
|
OK: 200,
|
|
CREATED: 201,
|
|
BAD_REQUEST: 400,
|
|
UNAUTHORIZED: 401,
|
|
FORBIDDEN: 403,
|
|
NOT_FOUND: 404,
|
|
CONFLICT: 409,
|
|
UNPROCESSABLE_ENTITY: 422,
|
|
TOO_MANY_REQUESTS: 429,
|
|
INSUFFICIENT_STORAGE: 507,
|
|
INTERNAL_SERVER_ERROR: 500,
|
|
} as const;
|
|
|
|
/**
|
|
* Error codes for client-side handling
|
|
*/
|
|
export const ErrorCode = {
|
|
// Authentication errors
|
|
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
FORBIDDEN: 'FORBIDDEN',
|
|
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
|
|
|
|
// Resource errors
|
|
NOT_FOUND: 'NOT_FOUND',
|
|
ALREADY_EXISTS: 'ALREADY_EXISTS',
|
|
|
|
// Validation errors
|
|
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
INVALID_INPUT: 'INVALID_INPUT',
|
|
|
|
// Rate limiting
|
|
RATE_LIMITED: 'RATE_LIMITED',
|
|
|
|
// Server errors
|
|
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
|
|
|
// 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;
|
|
|
|
/**
|
|
* Creates a standardized error response
|
|
*
|
|
* @param message - Human-readable error message
|
|
* @param status - HTTP status code
|
|
* @param code - Machine-readable error code for client handling
|
|
* @param details - Additional error details for validation errors (field -> messages[])
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* return errorResponse("Project not found", 404, ErrorCode.NOT_FOUND);
|
|
* return errorResponse("Invalid input", 400, ErrorCode.VALIDATION_ERROR, { email: ["Invalid email format"] });
|
|
* ```
|
|
*/
|
|
export function errorResponse(
|
|
message: string,
|
|
status: number,
|
|
code?: string,
|
|
details?: Record<string, string[]>
|
|
): NextResponse<ApiErrorResponse> {
|
|
const body: ApiErrorResponse = { error: message };
|
|
if (code) body.code = code;
|
|
if (details) {
|
|
// Sanitize: only allow string arrays to prevent accidental data leakage
|
|
const sanitized: Record<string, string[]> = {};
|
|
for (const [key, value] of Object.entries(details)) {
|
|
if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
|
|
sanitized[key] = value;
|
|
}
|
|
}
|
|
if (Object.keys(sanitized).length > 0) {
|
|
body.details = sanitized;
|
|
}
|
|
}
|
|
|
|
return NextResponse.json(body, { status });
|
|
}
|
|
|
|
/**
|
|
* Creates a standardized success response
|
|
*
|
|
* @param data - Response data
|
|
* @param status - HTTP status code (default: 200)
|
|
* @param meta - Pagination or other metadata (optional)
|
|
*
|
|
* Serialized with bigIntReplacer rather than NextResponse.json(), because
|
|
* JSON.stringify throws on BigInt and Prisma returns BigInt for sizeBytes.
|
|
* Any payload carrying a VideoVersion or VideoAsset row would otherwise 500
|
|
* after its write had already committed. BigInt values render as strings.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* return successResponse({ projects: [] });
|
|
* return successResponse({ projects: [] }, 200, { page: 1, limit: 10, total: 100 });
|
|
* ```
|
|
*/
|
|
export function successResponse<T>(
|
|
data: T,
|
|
status: number = HttpStatus.OK,
|
|
meta?: ApiSuccessResponse['meta']
|
|
): NextResponse<ApiSuccessResponse<T>> {
|
|
const body: ApiSuccessResponse<T> = { data };
|
|
if (meta) body.meta = meta;
|
|
|
|
return new NextResponse(JSON.stringify(body, bigIntReplacer), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' },
|
|
}) as NextResponse<ApiSuccessResponse<T>>;
|
|
}
|
|
|
|
export function withCacheControl(response: Response, value: string): Response {
|
|
response.headers.set('Cache-Control', value);
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Common error response helpers
|
|
*/
|
|
export const apiErrors = {
|
|
unauthorized: (message = 'Unauthorized') =>
|
|
errorResponse(message, HttpStatus.UNAUTHORIZED, ErrorCode.UNAUTHORIZED),
|
|
|
|
forbidden: (message = 'Forbidden') =>
|
|
errorResponse(message, HttpStatus.FORBIDDEN, ErrorCode.FORBIDDEN),
|
|
|
|
notFound: (resource = 'Resource') =>
|
|
errorResponse(`${resource} not found`, HttpStatus.NOT_FOUND, ErrorCode.NOT_FOUND),
|
|
|
|
badRequest: (message = 'Bad request') =>
|
|
errorResponse(message, HttpStatus.BAD_REQUEST, ErrorCode.INVALID_INPUT),
|
|
|
|
validationError: (message: string, details?: Record<string, string[]>) =>
|
|
errorResponse(message, HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_ERROR, details),
|
|
|
|
conflict: (message: string) =>
|
|
errorResponse(message, HttpStatus.CONFLICT, ErrorCode.ALREADY_EXISTS),
|
|
|
|
rateLimited: (message = 'Too many requests') =>
|
|
errorResponse(message, HttpStatus.TOO_MANY_REQUESTS, ErrorCode.RATE_LIMITED),
|
|
|
|
internalError: (message = 'Internal server error') =>
|
|
errorResponse(message, HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_ERROR),
|
|
|
|
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),
|
|
};
|