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; } /** * Standardized API success response format */ export interface ApiSuccessResponse { 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 ): NextResponse { 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 = {}; 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( data: T, status: number = HttpStatus.OK, meta?: ApiSuccessResponse['meta'] ): NextResponse> { const body: ApiSuccessResponse = { data }; if (meta) body.meta = meta; return new NextResponse(JSON.stringify(body, bigIntReplacer), { status, headers: { 'content-type': 'application/json' }, }) as NextResponse>; } 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) => 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), };