diff --git a/app/(dashboard)/error.tsx b/app/(dashboard)/error.tsx new file mode 100644 index 0000000..db3b0ce --- /dev/null +++ b/app/(dashboard)/error.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle } from "lucide-react"; + +export default function DashboardError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Dashboard error:", error); + }, [error]); + + return ( +
+
+ +

Dashboard Error

+

+ Something went wrong loading the dashboard. Your projects and videos are safe. +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} +
+
+ + +
+
+ ); +} diff --git a/app/(dashboard)/not-found.tsx b/app/(dashboard)/not-found.tsx new file mode 100644 index 0000000..dcee26c --- /dev/null +++ b/app/(dashboard)/not-found.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { FileQuestion } from "lucide-react"; + +export default function DashboardNotFound() { + return ( +
+
+ +

Page Not Found

+

+ The page you're looking for doesn't exist or has been moved. +

+
+
+ + +
+
+ ); +} diff --git a/app/(dashboard)/projects/[projectId]/not-found.tsx b/app/(dashboard)/projects/[projectId]/not-found.tsx new file mode 100644 index 0000000..f04dbb8 --- /dev/null +++ b/app/(dashboard)/projects/[projectId]/not-found.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { FolderX } from "lucide-react"; + +export default function ProjectNotFound() { + return ( +
+
+ +

Project Not Found

+

+ The project you're looking for doesn't exist or you don't have access to it. +

+
+
+ + +
+
+ ); +} diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/error.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/error.tsx new file mode 100644 index 0000000..84f5795 --- /dev/null +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/error.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle, Film } from "lucide-react"; + +export default function VideoError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Video player error:", error); + }, [error]); + + return ( +
+
+
+ + +
+

Video Player Error

+

+ Something went wrong with the video player. This could be due to a network issue or a problem with the video file. +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} +
+
+ + +
+
+ ); +} diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/not-found.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/not-found.tsx new file mode 100644 index 0000000..bd51e95 --- /dev/null +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/not-found.tsx @@ -0,0 +1,31 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Film } from "lucide-react"; + +interface VideoNotFoundProps { + params: Promise<{ projectId: string; videoId: string }>; +} + +export default async function VideoNotFound({ params }: VideoNotFoundProps) { + const { projectId } = await params; + + return ( +
+
+ +

Video Not Found

+

+ The video you're looking for doesn't exist or has been deleted. +

+
+
+ + +
+
+ ); +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 04860e5..8cc7c71 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import bcrypt from 'bcryptjs'; import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit'; +import { apiErrors, successResponse, ErrorCode } from '@/lib/api-response'; export async function POST(request: NextRequest) { try { @@ -11,13 +12,7 @@ export async function POST(request: NextRequest) { const rateLimit = await checkRateLimit(rateLimitKey, 'register'); if (!rateLimit.allowed) { - return NextResponse.json( - { error: 'Too many registration attempts. Please try again later.' }, - { - status: 429, - headers: rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests), - } - ); + return apiErrors.rateLimited('Too many registration attempts. Please try again later.'); } const body = await request.json(); @@ -26,10 +21,7 @@ export async function POST(request: NextRequest) { // Validate invite code using constant-time comparison to prevent timing attacks const validInviteCode = process.env.INVITE_CODE; if (!validInviteCode || !inviteCode) { - return NextResponse.json( - { error: 'Invalid invite code' }, - { status: 403 } - ); + return apiErrors.forbidden('Invalid invite code'); } // Constant-time comparison @@ -43,41 +35,26 @@ export async function POST(request: NextRequest) { const isValidCode = isValidLength && timingSafeEqual(validBuffer, compareBuffer); if (!isValidCode) { - return NextResponse.json( - { error: 'Invalid invite code' }, - { status: 403 } - ); + return apiErrors.forbidden('Invalid invite code'); } // Validate required fields if (!name || typeof name !== 'string' || name.trim().length < 2) { - return NextResponse.json( - { error: 'Name must be at least 2 characters' }, - { status: 400 } - ); + return apiErrors.badRequest('Name must be at least 2 characters'); } if (!email || typeof email !== 'string') { - return NextResponse.json( - { error: 'Email is required' }, - { status: 400 } - ); + return apiErrors.badRequest('Email is required'); } // Basic email validation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { - return NextResponse.json( - { error: 'Invalid email format' }, - { status: 400 } - ); + return apiErrors.validationError('Invalid email format'); } if (!password || typeof password !== 'string' || password.length < 8) { - return NextResponse.json( - { error: 'Password must be at least 8 characters' }, - { status: 400 } - ); + return apiErrors.badRequest('Password must be at least 8 characters'); } // Check if email already exists @@ -86,10 +63,7 @@ export async function POST(request: NextRequest) { }); if (existingUser) { - return NextResponse.json( - { error: 'An account with this email already exists' }, - { status: 409 } - ); + return apiErrors.conflict('An account with this email already exists'); } // Hash password @@ -110,9 +84,9 @@ export async function POST(request: NextRequest) { }, }); - const response = NextResponse.json( + const response = successResponse( { message: 'Account created successfully', user }, - { status: 201 } + 201 ); // Add rate limit headers to successful response @@ -124,9 +98,6 @@ export async function POST(request: NextRequest) { return response; } catch (error) { console.error('Registration error:', error); - return NextResponse.json( - { error: 'Failed to create account' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to create account'); } } diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts index 6642c12..9d77861 100644 --- a/app/api/comments/[commentId]/route.ts +++ b/app/api/comments/[commentId]/route.ts @@ -1,9 +1,10 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ commentId: string }> }; @@ -40,7 +41,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); if (!comment) { - return NextResponse.json({ error: 'Comment not found' }, { status: 404 }); + return apiErrors.notFound('Comment'); } // Authorization check: verify user has access to the project @@ -50,18 +51,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const isPublic = project.visibility === 'PUBLIC'; if (!isOwner && !isMember && !isPublic) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } // Strip internal project data from response const { version: _version, ...commentData } = comment; - return NextResponse.json(commentData); + return successResponse(commentData); } catch (error) { console.error('Error fetching comment:', error); - return NextResponse.json( - { error: 'Failed to fetch comment' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch comment'); } } @@ -75,7 +73,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const { commentId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const comment = await db.comment.findUnique({ @@ -98,7 +96,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }); if (!comment) { - return NextResponse.json({ error: 'Comment not found' }, { status: 404 }); + return apiErrors.notFound('Comment'); } const project = comment.version.video.project; @@ -111,18 +109,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { // Only author can edit content if (content !== undefined && !isAuthor) { - return NextResponse.json( - { error: 'Only the author can edit comment content' }, - { status: 403 } - ); + return apiErrors.forbidden('Only the author can edit comment content'); } // Owner, author, or members can resolve/unresolve if (isResolved !== undefined && !isOwner && !isAuthor && !isMember) { - return NextResponse.json( - { error: 'Access denied' }, - { status: 403 } - ); + return apiErrors.forbidden('Access denied'); } const updateData: Record = {}; @@ -145,13 +137,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json(updatedComment); + return successResponse(updatedComment); } catch (error) { console.error('Error updating comment:', error); - return NextResponse.json( - { error: 'Failed to update comment' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to update comment'); } } @@ -165,7 +154,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const { commentId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const comment = await db.comment.findUnique({ @@ -181,17 +170,14 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { }); if (!comment) { - return NextResponse.json({ error: 'Comment not found' }, { status: 404 }); + return apiErrors.notFound('Comment'); } const isOwner = comment.version.video.project.ownerId === session.user.id; const isAuthor = comment.authorId === session.user.id; if (!isOwner && !isAuthor) { - return NextResponse.json( - { error: 'Only the author or project owner can delete this comment' }, - { status: 403 } - ); + return apiErrors.forbidden('Only the author or project owner can delete this comment'); } // Collect all voice URLs to delete from R2 (comment + its replies) @@ -223,12 +209,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { } } - return NextResponse.json({ success: true, message: 'Comment deleted' }); + return successResponse({ message: 'Comment deleted' }); } catch (error) { console.error('Error deleting comment:', error); - return NextResponse.json( - { error: 'Failed to delete comment' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to delete comment'); } } diff --git a/app/api/projects/[projectId]/members/[memberId]/route.ts b/app/api/projects/[projectId]/members/[memberId]/route.ts index e039228..12d24b9 100644 --- a/app/api/projects/[projectId]/members/[memberId]/route.ts +++ b/app/api/projects/[projectId]/members/[memberId]/route.ts @@ -1,8 +1,9 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { ProjectMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string; memberId: string }> }; @@ -16,7 +17,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const { projectId, memberId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const project = await db.project.findUnique({ @@ -25,14 +26,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } const isOwner = project.ownerId === session.user.id; const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN; if (!isOwner && !isAdmin) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const body = await request.json(); @@ -40,10 +41,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const validRoles = ['ADMIN', 'COMMENTATOR']; if (!validRoles.includes(role)) { - return NextResponse.json( - { error: 'Invalid role. Must be ADMIN or COMMENTATOR.' }, - { status: 400 } - ); + return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.'); } const member = await db.projectMember.update({ @@ -54,13 +52,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json(member); + return successResponse(member); } catch (error) { console.error('Error updating member role:', error); - return NextResponse.json( - { error: 'Failed to update member role' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to update member role'); } } @@ -74,7 +69,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const { projectId, memberId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const project = await db.project.findUnique({ @@ -83,7 +78,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { }); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } const isOwner = project.ownerId === session.user.id; @@ -94,23 +89,20 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { }); if (!memberToRemove) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }); + return apiErrors.notFound('Member'); } const isSelf = memberToRemove.userId === session.user.id; if (!isOwner && !isAdmin && !isSelf) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } await db.projectMember.delete({ where: { id: memberId } }); - return NextResponse.json({ success: true, message: 'Member removed' }); + return successResponse({ message: 'Member removed' }); } catch (error) { console.error('Error removing member:', error); - return NextResponse.json( - { error: 'Failed to remove member' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to remove member'); } } diff --git a/app/api/projects/[projectId]/members/route.ts b/app/api/projects/[projectId]/members/route.ts index 4898b1d..31c8e00 100644 --- a/app/api/projects/[projectId]/members/route.ts +++ b/app/api/projects/[projectId]/members/route.ts @@ -1,8 +1,9 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { ProjectMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -13,7 +14,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const { projectId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const project = await db.project.findUnique({ @@ -24,14 +25,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } const isOwner = project.ownerId === session.user.id; const isMember = project.members.length > 0; if (!isOwner && !isMember) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const members = await db.projectMember.findMany({ @@ -47,13 +48,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) { select: { id: true, name: true, image: true }, }); - return NextResponse.json({ members, owner }); + return successResponse({ members, owner }); } catch (error) { console.error('Error fetching project members:', error); - return NextResponse.json( - { error: 'Failed to fetch members' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch members'); } } @@ -67,7 +65,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const { projectId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } // Check if user is owner or admin @@ -77,27 +75,21 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } const isOwner = project.ownerId === session.user.id; const isAdmin = project.members[0]?.role === ProjectMemberRole.ADMIN; if (!isOwner && !isAdmin) { - return NextResponse.json( - { error: 'Only project owners and admins can invite members' }, - { status: 403 } - ); + return apiErrors.forbidden('Only project owners and admins can invite members'); } const body = await request.json(); const { email, role } = body; if (!email || typeof email !== 'string') { - return NextResponse.json( - { error: 'Email is required' }, - { status: 400 } - ); + return apiErrors.badRequest('Email is required'); } // Validate role @@ -110,17 +102,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (!userToInvite) { - return NextResponse.json( - { message: 'If the user exists, an invitation has been sent.' }, - { status: 200 } - ); + return successResponse({ message: 'If the user exists, an invitation has been sent.' }); } if (userToInvite.id === project.ownerId) { - return NextResponse.json( - { error: 'Cannot invite the project owner as a member' }, - { status: 400 } - ); + return apiErrors.badRequest('Cannot invite the project owner as a member'); } // Check if already a member @@ -129,10 +115,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (existingMember) { - return NextResponse.json( - { error: 'User is already a member of this project' }, - { status: 409 } - ); + return apiErrors.conflict('User is already a member of this project'); } const member = await db.projectMember.create({ @@ -146,12 +129,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json(member, { status: 201 }); + return successResponse(member, 201); } catch (error) { console.error('Error inviting project member:', error); - return NextResponse.json( - { error: 'Failed to invite member' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to invite member'); } } diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts index 22f9109..211bd9f 100644 --- a/app/api/projects/[projectId]/route.ts +++ b/app/api/projects/[projectId]/route.ts @@ -4,6 +4,7 @@ import { auth } from '@/lib/auth'; import { ProjectMemberRole, ProjectVisibility } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; import { cleanupProjectVoiceFiles } from '@/lib/r2-cleanup'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -76,7 +77,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } // Check access @@ -103,16 +104,13 @@ export async function GET(request: NextRequest, { params }: RouteParams) { } if (!isPublic && !isOwner && !isMember && !isWorkspaceMember) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } - return NextResponse.json(project); + return successResponse(project); } catch (error) { console.error('Error fetching project:', error); - return NextResponse.json( - { error: 'Failed to fetch project' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch project'); } } @@ -126,12 +124,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const { projectId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const { canEdit } = await checkProjectAccess(projectId, session.user.id); if (!canEdit) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const body = await request.json(); @@ -151,13 +149,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json(project); + return successResponse(project); } catch (error) { console.error('Error updating project:', error); - return NextResponse.json( - { error: 'Failed to update project' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to update project'); } } @@ -171,20 +166,17 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const { projectId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const { canDelete, project } = await checkProjectAccess(projectId, session.user.id); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } if (!canDelete) { - return NextResponse.json( - { error: 'Only the project owner can delete it' }, - { status: 403 } - ); + return apiErrors.forbidden('Only the project owner can delete it'); } // Clean up voice files from R2 before cascade delete removes comment rows @@ -192,12 +184,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { await db.project.delete({ where: { id: projectId } }); - return NextResponse.json({ success: true, message: 'Project deleted' }); + return successResponse({ message: 'Project deleted' }); } catch (error) { console.error('Error deleting project:', error); - return NextResponse.json( - { error: 'Failed to delete project' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to delete project'); } } diff --git a/app/api/projects/[projectId]/tags/[tagId]/route.ts b/app/api/projects/[projectId]/tags/[tagId]/route.ts index c655310..8f70a4a 100644 --- a/app/api/projects/[projectId]/tags/[tagId]/route.ts +++ b/app/api/projects/[projectId]/tags/[tagId]/route.ts @@ -1,7 +1,8 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string; tagId: string }> }; @@ -46,15 +47,15 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const { projectId, tagId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const { canEdit, project } = await checkProjectAccess(projectId, session.user.id); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } if (!canEdit) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } // Verify tag belongs to this project @@ -62,7 +63,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { where: { id: tagId }, }); if (!existingTag || existingTag.projectId !== projectId) { - return NextResponse.json({ error: 'Tag not found' }, { status: 404 }); + return apiErrors.notFound('Tag'); } const body = await request.json(); @@ -71,13 +72,13 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const updateData: Record = {}; if (name !== undefined) { if (!name.trim()) { - return NextResponse.json({ error: 'Name cannot be empty' }, { status: 400 }); + return apiErrors.badRequest('Name cannot be empty'); } updateData.name = name.trim(); } if (color !== undefined) { if (!/^#[0-9A-Fa-f]{6}$/.test(color)) { - return NextResponse.json({ error: 'Invalid color format' }, { status: 400 }); + return apiErrors.badRequest('Invalid color format'); } updateData.color = color.toUpperCase(); } @@ -90,13 +91,13 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { data: updateData, }); - return NextResponse.json(tag); + return successResponse(tag); } catch (error) { console.error('Error updating tag:', error); if ((error as { code?: string }).code === 'P2002') { - return NextResponse.json({ error: 'Tag name already exists' }, { status: 409 }); + return apiErrors.conflict('Tag name already exists'); } - return NextResponse.json({ error: 'Failed to update tag' }, { status: 500 }); + return apiErrors.internalError('Failed to update tag'); } } @@ -110,15 +111,15 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const { projectId, tagId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const { canEdit, project } = await checkProjectAccess(projectId, session.user.id); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } if (!canEdit) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } // Verify tag belongs to this project @@ -126,14 +127,14 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { where: { id: tagId }, }); if (!existingTag || existingTag.projectId !== projectId) { - return NextResponse.json({ error: 'Tag not found' }, { status: 404 }); + return apiErrors.notFound('Tag'); } await db.commentTag.delete({ where: { id: tagId } }); - return NextResponse.json({ success: true }); + return successResponse({ message: 'Tag deleted' }); } catch (error) { console.error('Error deleting tag:', error); - return NextResponse.json({ error: 'Failed to delete tag' }, { status: 500 }); + return apiErrors.internalError('Failed to delete tag'); } } diff --git a/app/api/projects/[projectId]/tags/route.ts b/app/api/projects/[projectId]/tags/route.ts index d8b765e..35decb5 100644 --- a/app/api/projects/[projectId]/tags/route.ts +++ b/app/api/projects/[projectId]/tags/route.ts @@ -1,7 +1,8 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -52,12 +53,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const { projectId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const { project } = await checkProjectAccess(projectId, session.user.id); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } let tags = await db.commentTag.findMany({ @@ -78,10 +79,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); } - return NextResponse.json(tags); + return successResponse(tags); } catch (error) { console.error('Error fetching tags:', error); - return NextResponse.json({ error: 'Failed to fetch tags' }, { status: 500 }); + return apiErrors.internalError('Failed to fetch tags'); } } @@ -95,27 +96,27 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const { projectId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const { canEdit, project } = await checkProjectAccess(projectId, session.user.id); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } if (!canEdit) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const body = await request.json(); const { name, color } = body; if (!name?.trim() || !color?.trim()) { - return NextResponse.json({ error: 'Name and color are required' }, { status: 400 }); + return apiErrors.badRequest('Name and color are required'); } // Hex color validation if (!/^#[0-9A-Fa-f]{6}$/.test(color)) { - return NextResponse.json({ error: 'Invalid color format' }, { status: 400 }); + return apiErrors.badRequest('Invalid color format'); } // Get max position @@ -133,12 +134,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json(tag, { status: 201 }); + return successResponse(tag, 201); } catch (error) { console.error('Error creating tag:', error); if ((error as { code?: string }).code === 'P2002') { - return NextResponse.json({ error: 'Tag name already exists' }, { status: 409 }); + return apiErrors.conflict('Tag name already exists'); } - return NextResponse.json({ error: 'Failed to create tag' }, { status: 500 }); + return apiErrors.internalError('Failed to create tag'); } } diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index ec7d5dc..2d9662d 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -1,9 +1,10 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { ProjectMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; import { cleanupVideoVoiceFiles } from '@/lib/r2-cleanup'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; @@ -44,7 +45,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); if (!video) { - return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + return apiErrors.notFound('Video'); } // Check access @@ -53,19 +54,16 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const isPublic = video.project.visibility === 'PUBLIC'; if (!isOwner && !isMember && !isPublic) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } - return NextResponse.json({ + return successResponse({ ...video, isAuthenticated: !!session?.user?.id, }); } catch (error) { console.error('Error fetching video:', error); - return NextResponse.json( - { error: 'Failed to fetch video' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch video'); } } @@ -79,7 +77,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const { projectId, videoId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const video = await db.video.findFirst({ @@ -90,7 +88,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }); if (!video) { - return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + return apiErrors.notFound('Video'); } const isOwner = video.project.ownerId === session.user.id; @@ -99,7 +97,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { membership?.role === ProjectMemberRole.ADMIN; if (!canEdit) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const body = await request.json(); @@ -119,13 +117,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json(updatedVideo); + return successResponse(updatedVideo); } catch (error) { console.error('Error updating video:', error); - return NextResponse.json( - { error: 'Failed to update video' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to update video'); } } @@ -139,7 +134,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const { projectId, videoId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const video = await db.video.findFirst({ @@ -150,7 +145,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { }); if (!video) { - return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + return apiErrors.notFound('Video'); } const isOwner = video.project.ownerId === session.user.id; @@ -159,10 +154,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const canDelete = isOwner || membership?.role === ProjectMemberRole.ADMIN; if (!canDelete) { - return NextResponse.json( - { error: 'Only project owner or admin can delete videos' }, - { status: 403 } - ); + return apiErrors.forbidden('Only project owner or admin can delete videos'); } // Clean up voice files from R2 before cascade delete removes comment rows @@ -170,12 +162,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { await db.video.delete({ where: { id: videoId } }); - return NextResponse.json({ success: true, message: 'Video deleted' }); + return successResponse({ message: 'Video deleted' }); } catch (error) { console.error('Error deleting video:', error); - return NextResponse.json( - { error: 'Failed to delete video' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to delete video'); } } diff --git a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts index 3bb6c6e..8ac6f87 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/versions/route.ts @@ -1,9 +1,10 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { ProjectMemberRole } from '@prisma/client'; import { validateUrl, validateOptionalUrl } from '@/lib/validation'; import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; @@ -23,7 +24,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); if (!video) { - return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + return apiErrors.notFound('Video'); } const isOwner = session?.user?.id === video.project.ownerId; @@ -31,7 +32,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const isPublic = video.project.visibility === 'PUBLIC'; if (!isOwner && !isMember && !isPublic) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const versions = await db.videoVersion.findMany({ @@ -42,13 +43,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json({ versions }); + return successResponse({ versions }); } catch (error) { console.error('Error fetching versions:', error); - return NextResponse.json( - { error: 'Failed to fetch versions' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch versions'); } } @@ -62,7 +60,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const { projectId, videoId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const video = await db.video.findFirst({ @@ -74,7 +72,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (!video) { - return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + return apiErrors.notFound('Video'); } const isOwner = video.project.ownerId === session.user.id; @@ -83,28 +81,25 @@ export async function POST(request: NextRequest, { params }: RouteParams) { membership?.role === ProjectMemberRole.ADMIN; if (!canEdit) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const body = await request.json(); const { videoUrl, providerId, providerVideoId, versionLabel, thumbnailUrl, duration, setActive } = body; if (!videoUrl) { - return NextResponse.json( - { error: 'Video URL is required' }, - { status: 400 } - ); + return apiErrors.badRequest('Video URL is required'); } // Validate URLs use safe schemes (http/https only) const videoUrlError = validateUrl(videoUrl, 'Video URL'); if (videoUrlError) { - return NextResponse.json({ error: videoUrlError }, { status: 400 }); + return apiErrors.badRequest(videoUrlError); } const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL'); if (thumbnailUrlError) { - return NextResponse.json({ error: thumbnailUrlError }, { status: 400 }); + return apiErrors.badRequest(thumbnailUrlError); } const nextVersionNumber = (video.versions[0]?.versionNumber || 0) + 1; @@ -138,12 +133,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); }); - return NextResponse.json(version, { status: 201 }); + return successResponse(version, 201); } catch (error) { console.error('Error creating version:', error); - return NextResponse.json( - { error: 'Failed to create version' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to create version'); } } diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts index afecb96..d3a7555 100644 --- a/app/api/projects/[projectId]/videos/route.ts +++ b/app/api/projects/[projectId]/videos/route.ts @@ -1,10 +1,11 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { ProjectMemberRole } from '@prisma/client'; import { validateUrl, validateOptionalUrl } from '@/lib/validation'; import { rateLimit } from '@/lib/rate-limit'; import { notifyProjectOwner } from '@/lib/notifications'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -21,7 +22,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } const isOwner = session?.user?.id === project.ownerId; @@ -29,7 +30,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const isPublic = project.visibility === 'PUBLIC'; if (!isOwner && !isMember && !isPublic) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const videos = await db.video.findMany({ @@ -46,13 +47,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json({ videos }); + return successResponse({ videos }); } catch (error) { console.error('Error fetching videos:', error); - return NextResponse.json( - { error: 'Failed to fetch videos' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch videos'); } } @@ -66,7 +64,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const { projectId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } // Check project access (must be owner or admin) @@ -76,7 +74,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (!project) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return apiErrors.notFound('Project'); } const isOwner = project.ownerId === session.user.id; @@ -85,28 +83,25 @@ export async function POST(request: NextRequest, { params }: RouteParams) { membership?.role === ProjectMemberRole.ADMIN; if (!canEdit) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const body = await request.json(); const { title, description, videoUrl, providerId, videoId, thumbnailUrl, duration } = body; if (!title || !videoUrl) { - return NextResponse.json( - { error: 'Title and video URL are required' }, - { status: 400 } - ); + return apiErrors.badRequest('Title and video URL are required'); } // Validate URLs use safe schemes (http/https only) const videoUrlError = validateUrl(videoUrl, 'Video URL'); if (videoUrlError) { - return NextResponse.json({ error: videoUrlError }, { status: 400 }); + return apiErrors.badRequest(videoUrlError); } const thumbnailUrlError = validateOptionalUrl(thumbnailUrl, 'Thumbnail URL'); if (thumbnailUrlError) { - return NextResponse.json({ error: thumbnailUrlError }, { status: 400 }); + return apiErrors.badRequest(thumbnailUrlError); } // Get the next position @@ -154,12 +149,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }).catch((err) => console.error('Notification failed:', err)); } - return NextResponse.json(video, { status: 201 }); + return successResponse(video, 201); } catch (error) { console.error('Error creating video:', error); - return NextResponse.json( - { error: 'Failed to create video' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to create video'); } } diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index cff04c5..a32c4f6 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -3,6 +3,7 @@ import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { ProjectVisibility } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse } from '@/lib/api-response'; // GET /api/projects - List all projects for the authenticated user export async function GET(request: NextRequest) { @@ -10,7 +11,7 @@ export async function GET(request: NextRequest) { const session = await auth(); if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const { searchParams } = new URL(request.url); @@ -55,21 +56,19 @@ export async function GET(request: NextRequest) { }), ]); - return NextResponse.json({ - projects, - pagination: { + return successResponse( + { projects }, + 200, + { page, limit, total, totalPages: Math.ceil(total / limit), - }, - }); + } + ); } catch (error) { console.error('Error fetching projects:', error); - return NextResponse.json( - { error: 'Failed to fetch projects' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch projects'); } } @@ -82,24 +81,18 @@ export async function POST(request: NextRequest) { const session = await auth(); if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const body = await request.json(); const { name, description, visibility, workspaceId } = body; if (!name || typeof name !== 'string' || name.trim().length === 0) { - return NextResponse.json( - { error: 'Project name is required' }, - { status: 400 } - ); + return apiErrors.badRequest('Project name is required'); } if (!workspaceId || typeof workspaceId !== 'string') { - return NextResponse.json( - { error: 'A workspace is required. Every project must belong to a workspace.' }, - { status: 400 } - ); + return apiErrors.badRequest('A workspace is required. Every project must belong to a workspace.'); } // Generate URL-friendly slug @@ -127,17 +120,14 @@ export async function POST(request: NextRequest) { }); if (!workspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + return apiErrors.notFound('Workspace'); } const isWsOwner = workspace.ownerId === session.user.id; const isWsAdmin = workspace.members[0]?.role === 'ADMIN'; if (!isWsOwner && !isWsAdmin) { - return NextResponse.json( - { error: 'Only workspace owners and admins can create projects' }, - { status: 403 } - ); + return apiErrors.forbidden('Only workspace owners and admins can create projects'); } const project = await db.project.create({ @@ -155,12 +145,9 @@ export async function POST(request: NextRequest) { }, }); - return NextResponse.json(project, { status: 201 }); + return successResponse(project, 201); } catch (error) { console.error('Error creating project:', error); - return NextResponse.json( - { error: 'Failed to create project' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to create project'); } } diff --git a/app/api/settings/notifications/route.ts b/app/api/settings/notifications/route.ts index 71fdacf..c9d5d30 100644 --- a/app/api/settings/notifications/route.ts +++ b/app/api/settings/notifications/route.ts @@ -1,16 +1,17 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; import nodemailer from 'nodemailer'; import { testEmailHtml } from '@/lib/notifications'; +import { apiErrors, successResponse } from '@/lib/api-response'; // GET /api/settings/notifications — Fetch current notification preferences export async function GET() { try { const session = await auth(); if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const settings = await db.notificationSetting.findUnique({ @@ -18,7 +19,7 @@ export async function GET() { }); // Return defaults if no settings exist yet - return NextResponse.json( + return successResponse( settings ?? { telegramBotToken: null, telegramChatId: null, @@ -32,10 +33,7 @@ export async function GET() { ); } catch (error) { console.error('Error fetching notification settings:', error); - return NextResponse.json( - { error: 'Failed to fetch settings' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch settings'); } } @@ -47,7 +45,7 @@ export async function PUT(request: NextRequest) { const session = await auth(); if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const body = await request.json(); @@ -64,10 +62,7 @@ export async function PUT(request: NextRequest) { // Validate: if enabling Telegram, both token and chatId are required if (telegramEnabled && (!telegramBotToken || !telegramChatId)) { - return NextResponse.json( - { error: 'Telegram Bot Token and Chat ID are required to enable Telegram notifications' }, - { status: 400 } - ); + return apiErrors.badRequest('Telegram Bot Token and Chat ID are required to enable Telegram notifications'); } const settings = await db.notificationSetting.upsert({ @@ -95,13 +90,10 @@ export async function PUT(request: NextRequest) { }, }); - return NextResponse.json(settings); + return successResponse(settings); } catch (error) { console.error('Error updating notification settings:', error); - return NextResponse.json( - { error: 'Failed to update settings' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to update settings'); } } @@ -113,7 +105,7 @@ export async function POST(request: NextRequest) { const session = await auth(); if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const body = await request.json(); @@ -121,10 +113,7 @@ export async function POST(request: NextRequest) { if (channel === 'telegram') { if (!telegramBotToken || !telegramChatId) { - return NextResponse.json( - { error: 'Bot Token and Chat ID are required' }, - { status: 400 } - ); + return apiErrors.badRequest('Bot Token and Chat ID are required'); } const settingsUrl = `${process.env.NEXTAUTH_URL || ''}/settings`; @@ -148,13 +137,10 @@ export async function POST(request: NextRequest) { if (!res.ok) { const data = await res.json().catch(() => ({})); const desc = (data as { description?: string }).description || 'Unknown error'; - return NextResponse.json( - { error: `Telegram test failed: ${desc}` }, - { status: 400 } - ); + return apiErrors.badRequest(`Telegram test failed: ${desc}`); } - return NextResponse.json({ success: true, message: 'Test message sent to Telegram' }); + return successResponse({ message: 'Test message sent to Telegram' }); } if (channel === 'email') { @@ -164,10 +150,7 @@ export async function POST(request: NextRequest) { }); if (!user?.email) { - return NextResponse.json( - { error: 'No email address on your account' }, - { status: 400 } - ); + return apiErrors.badRequest('No email address on your account'); } const smtpHost = process.env.SMTP_HOST; @@ -176,10 +159,7 @@ export async function POST(request: NextRequest) { const smtpPass = process.env.SMTP_PASSWORD; if (!smtpHost || !smtpUser || !smtpPass) { - return NextResponse.json( - { error: 'Email service not configured (SMTP settings missing)' }, - { status: 500 } - ); + return apiErrors.internalError('Email service not configured (SMTP settings missing)'); } const transporter = nodemailer.createTransport({ @@ -200,21 +180,15 @@ export async function POST(request: NextRequest) { }); } catch (emailErr) { console.error('SMTP test email failed:', emailErr); - return NextResponse.json( - { error: 'Failed to send test email — check SMTP settings' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to send test email — check SMTP settings'); } - return NextResponse.json({ success: true, message: `Test email sent to ${user.email}` }); + return successResponse({ message: `Test email sent to ${user.email}` }); } - return NextResponse.json({ error: 'Unknown channel' }, { status: 400 }); + return apiErrors.badRequest('Unknown channel'); } catch (error) { console.error('Error testing notification:', error); - return NextResponse.json( - { error: 'Failed to test notification' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to test notification'); } } diff --git a/app/api/upload/audio/[filename]/route.ts b/app/api/upload/audio/[filename]/route.ts index 9920bfc..94b357f 100644 --- a/app/api/upload/audio/[filename]/route.ts +++ b/app/api/upload/audio/[filename]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { apiErrors } from '@/lib/api-response'; // Only allow UUID filenames with safe extensions const SAFE_FILENAME = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i; @@ -14,7 +15,7 @@ export async function GET( // Validate filename to prevent path traversal if (!SAFE_FILENAME.test(filename)) { - return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }); + return apiErrors.badRequest('Invalid filename'); } const key = `voice/${filename}`; @@ -27,7 +28,7 @@ export async function GET( ); if (!response.Body) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }); + return apiErrors.notFound('File'); } const contentType = response.ContentType || 'audio/webm'; @@ -47,12 +48,9 @@ export async function GET( } catch (error: unknown) { const errorName = error instanceof Error ? error.name : ''; if (errorName === 'NoSuchKey') { - return NextResponse.json({ error: 'File not found' }, { status: 404 }); + return apiErrors.notFound('File'); } console.error('Error serving audio:', error); - return NextResponse.json( - { error: 'Failed to retrieve audio' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to retrieve audio'); } } diff --git a/app/api/upload/audio/route.ts b/app/api/upload/audio/route.ts index cd03733..969a6b6 100644 --- a/app/api/upload/audio/route.ts +++ b/app/api/upload/audio/route.ts @@ -1,9 +1,9 @@ -import { NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; import { r2Client, R2_BUCKET_NAME } from '@/lib/r2'; import { PutObjectCommand } from '@aws-sdk/client-s3'; import { randomUUID } from 'crypto'; import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse } from '@/lib/api-response'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav']; @@ -17,30 +17,24 @@ export async function POST(request: Request) { // Require authentication const session = await auth(); if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const formData = await request.formData(); const file = formData.get('audio') as File | null; if (!file) { - return NextResponse.json({ error: 'No audio file provided' }, { status: 400 }); + return apiErrors.badRequest('No audio file provided'); } if (file.size > MAX_FILE_SIZE) { - return NextResponse.json( - { error: 'File too large. Maximum size is 10MB.' }, - { status: 400 } - ); + return apiErrors.badRequest('File too large. Maximum size is 10MB.'); } // Check content type const contentType = file.type || 'audio/webm'; if (!ALLOWED_TYPES.includes(contentType)) { - return NextResponse.json( - { error: `Unsupported audio format: ${contentType}` }, - { status: 400 } - ); + return apiErrors.badRequest(`Unsupported audio format: ${contentType}`); } // Generate unique filename @@ -65,12 +59,9 @@ export async function POST(request: Request) { // Return the URL through our proxy endpoint const voiceUrl = `/api/upload/audio/${filename}`; - return NextResponse.json({ url: voiceUrl }, { status: 201 }); + return successResponse({ url: voiceUrl }, 201); } catch (error) { console.error('Error uploading audio:', error); - return NextResponse.json( - { error: 'Failed to upload audio' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to upload audio'); } } diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index a2d25cc..15ef01d 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -1,9 +1,10 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { validateOptionalUrl } from '@/lib/validation'; import { rateLimit } from '@/lib/rate-limit'; import { notifyProjectOwner } from '@/lib/notifications'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ versionId: string }> }; @@ -30,7 +31,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); if (!version) { - return NextResponse.json({ error: 'Version not found' }, { status: 404 }); + return apiErrors.notFound('Version'); } const project = version.video.project; @@ -39,7 +40,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const isPublic = project.visibility === 'PUBLIC'; if (!isOwner && !isMember && !isPublic) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const { searchParams } = new URL(request.url); @@ -65,13 +66,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json({ comments }); + return successResponse({ comments }); } catch (error) { console.error('Error fetching comments:', error); - return NextResponse.json( - { error: 'Failed to fetch comments' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch comments'); } } @@ -101,7 +99,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (!version) { - return NextResponse.json({ error: 'Version not found' }, { status: 404 }); + return apiErrors.notFound('Version'); } const project = version.video.project; @@ -113,7 +111,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { // Check if user can comment const canComment = isOwner || isMember || isPublic || hasCommentLink; if (!canComment) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const body = await request.json(); @@ -121,17 +119,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) { // Validate required fields if (timestamp === undefined || timestamp === null) { - return NextResponse.json( - { error: 'Timestamp is required' }, - { status: 400 } - ); + return apiErrors.badRequest('Timestamp is required'); } if (!content && !voiceUrl) { - return NextResponse.json( - { error: 'Either content or voice recording is required' }, - { status: 400 } - ); + return apiErrors.badRequest('Either content or voice recording is required'); } // If replying, verify parent exists in same version @@ -140,27 +132,21 @@ export async function POST(request: NextRequest, { params }: RouteParams) { where: { id: parentId, versionId }, }); if (!parent) { - return NextResponse.json( - { error: 'Parent comment not found' }, - { status: 400 } - ); + return apiErrors.badRequest('Parent comment not found'); } } // Guest comment validation const isGuest = !session?.user?.id; if (isGuest && !guestName) { - return NextResponse.json( - { error: 'Guest name is required for guest comments' }, - { status: 400 } - ); + return apiErrors.badRequest('Guest name is required for guest comments'); } // Validate voice URL uses safe scheme (allow internal /api/ paths) if (voiceUrl && !voiceUrl.startsWith('/api/')) { const voiceUrlError = validateOptionalUrl(voiceUrl, 'Voice URL'); if (voiceUrlError) { - return NextResponse.json({ error: voiceUrlError }, { status: 400 }); + return apiErrors.badRequest(voiceUrlError); } } @@ -229,12 +215,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } } - return NextResponse.json(comment, { status: 201 }); + return successResponse(comment, 201); } catch (error) { console.error('Error creating comment:', error); - return NextResponse.json( - { error: 'Failed to create comment' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to create comment'); } } diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index 1c67197..2e5ca65 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -1,6 +1,7 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ videoId: string }> }; @@ -43,7 +44,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); if (!video) { - return NextResponse.json({ error: 'Video not found' }, { status: 404 }); + return apiErrors.notFound('Video'); } // Check access @@ -52,12 +53,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const isPublic = video.project.visibility === 'PUBLIC'; if (!isOwner && !isMember && !isPublic) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } // Include auth context so the client knows if the viewer is a guest const { project, ...videoData } = video; - return NextResponse.json({ + return successResponse({ ...videoData, projectId: video.projectId, project: { @@ -70,9 +71,6 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); } catch (error) { console.error('Error fetching video:', error); - return NextResponse.json( - { error: 'Failed to fetch video' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch video'); } } diff --git a/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts b/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts index e2d470c..90615bd 100644 --- a/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts +++ b/app/api/workspaces/[workspaceId]/members/[memberId]/route.ts @@ -1,8 +1,9 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { WorkspaceMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ workspaceId: string; memberId: string }> }; @@ -16,7 +17,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const { workspaceId, memberId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } // Check if user is owner or admin @@ -26,14 +27,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }); if (!workspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + return apiErrors.notFound('Workspace'); } const isOwner = workspace.ownerId === session.user.id; const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN; if (!isOwner && !isAdmin) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const body = await request.json(); @@ -41,10 +42,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const validRoles = ['ADMIN', 'COMMENTATOR']; if (!validRoles.includes(role)) { - return NextResponse.json( - { error: 'Invalid role. Must be ADMIN or COMMENTATOR.' }, - { status: 400 } - ); + return apiErrors.badRequest('Invalid role. Must be ADMIN or COMMENTATOR.'); } const member = await db.workspaceMember.update({ @@ -55,13 +53,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json(member); + return successResponse(member); } catch (error) { console.error('Error updating member role:', error); - return NextResponse.json( - { error: 'Failed to update member role' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to update member role'); } } @@ -75,7 +70,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const { workspaceId, memberId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const workspace = await db.workspace.findUnique({ @@ -84,7 +79,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { }); if (!workspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + return apiErrors.notFound('Workspace'); } const isOwner = workspace.ownerId === session.user.id; @@ -96,23 +91,20 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { }); if (!memberToRemove) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }); + return apiErrors.notFound('Member'); } const isSelf = memberToRemove.userId === session.user.id; if (!isOwner && !isAdmin && !isSelf) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } await db.workspaceMember.delete({ where: { id: memberId } }); - return NextResponse.json({ success: true, message: 'Member removed' }); + return successResponse({ message: 'Member removed' }); } catch (error) { console.error('Error removing member:', error); - return NextResponse.json( - { error: 'Failed to remove member' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to remove member'); } } diff --git a/app/api/workspaces/[workspaceId]/members/route.ts b/app/api/workspaces/[workspaceId]/members/route.ts index cfd635b..02a327f 100644 --- a/app/api/workspaces/[workspaceId]/members/route.ts +++ b/app/api/workspaces/[workspaceId]/members/route.ts @@ -1,8 +1,9 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { WorkspaceMemberRole } from '@prisma/client'; import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ workspaceId: string }> }; @@ -13,7 +14,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const { workspaceId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const workspace = await db.workspace.findUnique({ @@ -24,14 +25,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); if (!workspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + return apiErrors.notFound('Workspace'); } const isOwner = workspace.ownerId === session.user.id; const isMember = workspace.members.length > 0; if (!isOwner && !isMember) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const members = await db.workspaceMember.findMany({ @@ -48,13 +49,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) { select: { id: true, name: true, image: true }, }); - return NextResponse.json({ members, owner }); + return successResponse({ members, owner }); } catch (error) { console.error('Error fetching workspace members:', error); - return NextResponse.json( - { error: 'Failed to fetch members' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch members'); } } @@ -68,7 +66,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const { workspaceId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } // Check if user is owner or admin @@ -78,27 +76,21 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (!workspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + return apiErrors.notFound('Workspace'); } const isOwner = workspace.ownerId === session.user.id; const isAdmin = workspace.members[0]?.role === WorkspaceMemberRole.ADMIN; if (!isOwner && !isAdmin) { - return NextResponse.json( - { error: 'Only workspace owners and admins can invite members' }, - { status: 403 } - ); + return apiErrors.forbidden('Only workspace owners and admins can invite members'); } const body = await request.json(); const { email, role } = body; if (!email || typeof email !== 'string') { - return NextResponse.json( - { error: 'Email is required' }, - { status: 400 } - ); + return apiErrors.badRequest('Email is required'); } // Validate role @@ -111,17 +103,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (!userToInvite) { - return NextResponse.json( - { message: 'If the user exists, an invitation has been sent.' }, - { status: 200 } - ); + return successResponse({ message: 'If the user exists, an invitation has been sent.' }); } if (userToInvite.id === workspace.ownerId) { - return NextResponse.json( - { error: 'Cannot invite the workspace owner as a member' }, - { status: 400 } - ); + return apiErrors.badRequest('Cannot invite the workspace owner as a member'); } // Check if already a member @@ -130,10 +116,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (existingMember) { - return NextResponse.json( - { error: 'User is already a member of this workspace' }, - { status: 409 } - ); + return apiErrors.conflict('User is already a member of this workspace'); } const member = await db.workspaceMember.create({ @@ -147,12 +130,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json(member, { status: 201 }); + return successResponse(member, 201); } catch (error) { console.error('Error inviting workspace member:', error); - return NextResponse.json( - { error: 'Failed to invite member' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to invite member'); } } diff --git a/app/api/workspaces/[workspaceId]/route.ts b/app/api/workspaces/[workspaceId]/route.ts index d19d1f4..c0a465a 100644 --- a/app/api/workspaces/[workspaceId]/route.ts +++ b/app/api/workspaces/[workspaceId]/route.ts @@ -1,8 +1,9 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; import { cleanupWorkspaceVoiceFiles } from '@/lib/r2-cleanup'; +import { apiErrors, successResponse } from '@/lib/api-response'; type RouteParams = { params: Promise<{ workspaceId: string }> }; @@ -36,7 +37,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const { workspaceId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const workspace = await db.workspace.findUnique({ @@ -59,7 +60,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); if (!workspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + return apiErrors.notFound('Workspace'); } // Check access @@ -67,16 +68,13 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const isMember = workspace.members.some(m => m.userId === session?.user?.id); if (!isOwner && !isMember) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } - return NextResponse.json(workspace); + return successResponse(workspace); } catch (error) { console.error('Error fetching workspace:', error); - return NextResponse.json( - { error: 'Failed to fetch workspace' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch workspace'); } } @@ -90,12 +88,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const { workspaceId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const { isAdmin } = await checkWorkspaceAccess(workspaceId, session.user.id); if (!isAdmin) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + return apiErrors.forbidden('Access denied'); } const body = await request.json(); @@ -114,13 +112,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { }, }); - return NextResponse.json(workspace); + return successResponse(workspace); } catch (error) { console.error('Error updating workspace:', error); - return NextResponse.json( - { error: 'Failed to update workspace' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to update workspace'); } } @@ -134,20 +129,17 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { const { workspaceId } = await params; if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const { isOwner, workspace } = await checkWorkspaceAccess(workspaceId, session.user.id); if (!workspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); + return apiErrors.notFound('Workspace'); } if (!isOwner) { - return NextResponse.json( - { error: 'Only the workspace owner can delete it' }, - { status: 403 } - ); + return apiErrors.forbidden('Only the workspace owner can delete it'); } // Clean up voice files from R2 before cascade delete removes comment rows @@ -155,12 +147,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { await db.workspace.delete({ where: { id: workspaceId } }); - return NextResponse.json({ success: true, message: 'Workspace deleted' }); + return successResponse({ message: 'Workspace deleted' }); } catch (error) { console.error('Error deleting workspace:', error); - return NextResponse.json( - { error: 'Failed to delete workspace' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to delete workspace'); } } diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts index 8ed52b3..4ce2e7d 100644 --- a/app/api/workspaces/route.ts +++ b/app/api/workspaces/route.ts @@ -1,7 +1,8 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; +import { apiErrors, successResponse } from '@/lib/api-response'; // GET /api/workspaces - List all workspaces for the authenticated user export async function GET() { @@ -9,7 +10,7 @@ export async function GET() { const session = await auth(); if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } // Get workspaces where user is owner OR a member @@ -27,13 +28,10 @@ export async function GET() { orderBy: { updatedAt: 'desc' }, }); - return NextResponse.json({ workspaces }); + return successResponse({ workspaces }); } catch (error) { console.error('Error fetching workspaces:', error); - return NextResponse.json( - { error: 'Failed to fetch workspaces' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to fetch workspaces'); } } @@ -46,17 +44,14 @@ export async function POST(request: NextRequest) { const session = await auth(); if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return apiErrors.unauthorized(); } const body = await request.json(); const { name, description } = body; if (!name || typeof name !== 'string' || name.trim().length === 0) { - return NextResponse.json( - { error: 'Workspace name is required' }, - { status: 400 } - ); + return apiErrors.badRequest('Workspace name is required'); } // Generate slug @@ -89,12 +84,9 @@ export async function POST(request: NextRequest) { }, }); - return NextResponse.json(workspace, { status: 201 }); + return successResponse(workspace, 201); } catch (error) { console.error('Error creating workspace:', error); - return NextResponse.json( - { error: 'Failed to create workspace' }, - { status: 500 } - ); + return apiErrors.internalError('Failed to create workspace'); } } diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..0e25f74 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle } from "lucide-react"; + +export default function RootError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Root error:", error); + }, [error]); + + return ( +
+
+ +

Something went wrong

+

+ An unexpected error occurred. We've been notified and are working to fix it. +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} +
+
+ + +
+
+ ); +} diff --git a/app/watch/[videoId]/error.tsx b/app/watch/[videoId]/error.tsx new file mode 100644 index 0000000..b3ab992 --- /dev/null +++ b/app/watch/[videoId]/error.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle, Film } from "lucide-react"; + +export default function WatchError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Watch page error:", error); + }, [error]); + + return ( +
+
+
+ + +
+

Video Unavailable

+

+ We couldn't load this video. It may have been removed or the link might be incorrect. +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} +
+
+ + +
+
+ ); +} diff --git a/app/watch/[videoId]/not-found.tsx b/app/watch/[videoId]/not-found.tsx new file mode 100644 index 0000000..99d1d6d --- /dev/null +++ b/app/watch/[videoId]/not-found.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Film, XCircle } from "lucide-react"; + +export default function WatchNotFound() { + return ( +
+
+
+ + +
+

Video Not Found

+

+ The video you're looking for doesn't exist, has been removed, or the link may be expired. +

+
+
+ +
+
+ ); +} diff --git a/components/error-boundary.tsx b/components/error-boundary.tsx new file mode 100644 index 0000000..f6d95c5 --- /dev/null +++ b/components/error-boundary.tsx @@ -0,0 +1,139 @@ +"use client"; + +import React, { Component, ErrorInfo, ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle, Film } from "lucide-react"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; + context?: string; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * Global Error Boundary component for catching React component errors. + * Use this to wrap client components that might crash, especially the video player. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error(`ErrorBoundary${this.props.context ? ` [${this.props.context}]` : ""} caught an error:`, error, errorInfo); + this.props.onError?.(error, errorInfo); + } + + private handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + private handleReload = () => { + window.location.reload(); + }; + + public render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( + + ); + } + + return this.props.children; + } +} + +interface ErrorFallbackProps { + error: Error | null; + context?: string; + onReset: () => void; + onReload: () => void; +} + +function ErrorFallback({ error, context, onReset, onReload }: ErrorFallbackProps) { + const isVideoContext = context?.toLowerCase().includes("video"); + + return ( +
+
+ {isVideoContext ? ( + <> + + + + ) : ( + + )} +
+ +
+

+ {context ? `${context} crashed` : "Something went wrong"} +

+

+ {isVideoContext + ? "The video player encountered an error. Try reloading or go back to the project." + : "An unexpected error occurred. Try resetting the component or reload the page."} +

+
+ + {process.env.NODE_ENV === "development" && error?.message && ( +
+ {error.message} +
+ )} + +
+ + +
+
+ ); +} + +/** + * HOC to wrap a component with ErrorBoundary + */ +export function withErrorBoundary

( + Component: React.ComponentType

, + errorBoundaryProps?: Omit +) { + return function WithErrorBoundaryWrapper(props: P) { + return ( + + + + ); + }; +} diff --git a/lib/api-response.ts b/lib/api-response.ts new file mode 100644 index 0000000..7dec32a --- /dev/null +++ b/lib/api-response.ts @@ -0,0 +1,156 @@ +import { NextResponse } from "next/server"; + +/** + * 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, + 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", +} 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) + * + * @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 NextResponse.json(body, { status }); +} + +/** + * 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), +};