feat: add pagination and strict query validation for workspace/project listings

This commit is contained in:
Yusuf İpek
2026-02-24 18:07:04 +03:00
parent 9f32aeafe6
commit 644b239ab3
6 changed files with 236 additions and 65 deletions
@@ -44,16 +44,27 @@ function formatRelativeTime(date: Date): string {
interface WorkspacePageProps { interface WorkspacePageProps {
params: Promise<{ workspaceId: string }>; params: Promise<{ workspaceId: string }>;
searchParams: Promise<{ page?: string }>;
} }
export default async function WorkspacePage({ params }: WorkspacePageProps) { export default async function WorkspacePage({ params, searchParams }: WorkspacePageProps) {
const session = await auth(); const session = await auth();
const { workspaceId } = await params; const { workspaceId } = await params;
const resolvedSearchParams = await searchParams;
const MAX_PAGE = 1000;
if (!session?.user?.id) { if (!session?.user?.id) {
redirect('/login'); redirect('/login');
} }
const pageParam = resolvedSearchParams?.page;
const parsedPage = pageParam ? Number(pageParam) : 1;
const page = Number.isSafeInteger(parsedPage) && parsedPage > 0 && parsedPage <= MAX_PAGE
? parsedPage
: 1;
const pageSize = 20;
const skip = (page - 1) * pageSize;
const workspace = await db.workspace.findUnique({ const workspace = await db.workspace.findUnique({
where: { id: workspaceId }, where: { id: workspaceId },
include: { include: {
@@ -64,6 +75,8 @@ export default async function WorkspacePage({ params }: WorkspacePageProps) {
}, },
projects: { projects: {
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
skip,
take: pageSize,
include: { include: {
_count: { select: { videos: true, members: true } }, _count: { select: { videos: true, members: true } },
}, },
@@ -85,6 +98,8 @@ export default async function WorkspacePage({ params }: WorkspacePageProps) {
redirect('/workspaces'); redirect('/workspaces');
} }
const totalPages = Math.ceil(workspace._count.projects / pageSize);
return ( return (
<div className="px-6 lg:px-8 py-8 w-full"> <div className="px-6 lg:px-8 py-8 w-full">
{/* Back & Header */} {/* Back & Header */}
@@ -143,42 +158,65 @@ export default async function WorkspacePage({ params }: WorkspacePageProps) {
{/* Projects Grid */} {/* Projects Grid */}
{workspace.projects.length > 0 ? ( {workspace.projects.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <>
{workspace.projects.map((project: (typeof workspace.projects)[number]) => ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Link key={project.id} href={`/projects/${project.id}`}> {workspace.projects.map((project: (typeof workspace.projects)[number]) => (
<Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer"> <Link key={project.id} href={`/projects/${project.id}`}>
<CardHeader> <Card className="h-full transition-colors hover:bg-accent/50 cursor-pointer">
<div className="flex items-start justify-between"> <CardHeader>
<CardTitle className="flex items-center gap-2"> <div className="flex items-start justify-between">
<FolderOpen className="h-5 w-5 text-primary" /> <CardTitle className="flex items-center gap-2">
{project.name} <FolderOpen className="h-5 w-5 text-primary" />
</CardTitle> {project.name}
<Badge variant="outline" className="flex items-center gap-1"> </CardTitle>
<VisibilityIcon visibility={project.visibility} /> <Badge variant="outline" className="flex items-center gap-1">
{project.visibility.toLowerCase()} <VisibilityIcon visibility={project.visibility} />
</Badge> {project.visibility.toLowerCase()}
</div> </Badge>
<CardDescription className="line-clamp-2"> </div>
{project.description || 'No description'} <CardDescription className="line-clamp-2">
</CardDescription> {project.description || 'No description'}
</CardHeader> </CardDescription>
<CardContent> </CardHeader>
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <CardContent>
<span className="flex items-center gap-1"> <div className="flex items-center gap-4 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5" /> <span className="flex items-center gap-1">
{formatRelativeTime(project.updatedAt)} <Clock className="h-3.5 w-3.5" />
</span> {formatRelativeTime(project.updatedAt)}
<span className="flex items-center gap-1"> </span>
<Users className="h-3.5 w-3.5" /> <span className="flex items-center gap-1">
{project._count.members + 1} <Users className="h-3.5 w-3.5" />
</span> {project._count.members + 1}
<span>{project._count.videos} videos</span> </span>
</div> <span>{project._count.videos} videos</span>
</CardContent> </div>
</Card> </CardContent>
</Link> </Card>
))} </Link>
</div> ))}
</div>
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-end space-x-2">
<Button variant="outline" size="sm" disabled={page <= 1} asChild={page > 1}>
{page > 1 ? (
<Link href={`/workspaces/${workspaceId}?page=${page - 1}`}>Previous</Link>
) : (
'Previous'
)}
</Button>
<span className="text-sm font-medium">
Page {page} of {totalPages}
</span>
<Button variant="outline" size="sm" disabled={page >= totalPages} asChild={page < totalPages}>
{page < totalPages ? (
<Link href={`/workspaces/${workspaceId}?page=${page + 1}`}>Next</Link>
) : (
'Next'
)}
</Button>
</div>
)}
</>
) : ( ) : (
<Card className="border-dashed"> <Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16"> <CardContent className="flex flex-col items-center justify-center py-16">
+16 -2
View File
@@ -12,11 +12,25 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
try { try {
const session = await auth(); const session = await auth();
const { projectId } = await params; const { projectId } = await params;
const MAX_LIMIT = 100;
const MAX_OFFSET = 10000;
// Parse pagination params // Parse pagination params
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100); const limitParam = searchParams.get('limit');
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0')); const offsetParam = searchParams.get('offset');
const limitRaw = limitParam === null ? 20 : Number(limitParam);
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
}
const offset = offsetParam === null ? 0 : Number(offsetParam);
if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) {
return apiErrors.badRequest('Invalid offset. Must be a non-negative integer up to 10000.');
}
const limit = limitRaw;
const project = await db.project.findUnique({ const project = await db.project.findUnique({
where: { id: projectId }, where: { id: projectId },
+21 -2
View File
@@ -10,16 +10,35 @@ import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const session = await auth(); const session = await auth();
const MAX_LIMIT = 100;
const MAX_PAGE = 1000;
const MAX_OFFSET = 10000;
if (!session?.user?.id) { if (!session?.user?.id) {
return apiErrors.unauthorized(); return apiErrors.unauthorized();
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1'); const pageParam = searchParams.get('page');
const limit = parseInt(searchParams.get('limit') || '10'); const limitParam = searchParams.get('limit');
const workspaceId = searchParams.get('workspaceId'); const workspaceId = searchParams.get('workspaceId');
const pageRaw = pageParam === null ? 1 : Number(pageParam);
if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) {
return apiErrors.badRequest('Invalid page. Must be a positive integer.');
}
const limitRaw = limitParam === null ? 10 : Number(limitParam);
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
}
const page = pageRaw;
const limit = limitRaw;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) {
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
}
// Build base filter: user is owner OR a member // Build base filter: user is owner OR a member
const baseFilter: Record<string, unknown> = { const baseFilter: Record<string, unknown> = {
@@ -12,11 +12,35 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
try { try {
const session = await auth(); const session = await auth();
const { workspaceId } = await params; const { workspaceId } = await params;
const MAX_LIMIT = 100;
const MAX_PAGE = 1000;
const MAX_OFFSET = 10000;
if (!session?.user?.id) { if (!session?.user?.id) {
return apiErrors.unauthorized(); return apiErrors.unauthorized();
} }
const searchParams = request.nextUrl.searchParams;
const pageParam = searchParams.get('page');
const limitParam = searchParams.get('limit');
const pageRaw = pageParam === null ? 1 : Number(pageParam);
if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) {
return apiErrors.badRequest('Invalid page. Must be a positive integer.');
}
const limitRaw = limitParam === null ? 20 : Number(limitParam);
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
}
const page = pageRaw;
const limit = limitRaw;
const skip = (page - 1) * limit;
if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) {
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
}
const workspace = await db.workspace.findUnique({ const workspace = await db.workspace.findUnique({
where: { id: workspaceId }, where: { id: workspaceId },
include: { include: {
@@ -35,13 +59,20 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
return apiErrors.forbidden('Access denied'); return apiErrors.forbidden('Access denied');
} }
const members = await db.workspaceMember.findMany({ const [members, total] = await Promise.all([
where: { workspaceId }, db.workspaceMember.findMany({
include: { where: { workspaceId },
user: { select: { id: true, name: true, email: true, image: true } }, include: {
}, user: { select: { id: true, name: true, email: true, image: true } },
orderBy: { createdAt: 'asc' }, },
}); orderBy: { createdAt: 'asc' },
skip,
take: limit,
}),
db.workspaceMember.count({
where: { workspaceId },
}),
]);
// Include the owner as well // Include the owner as well
const owner = await db.user.findUnique({ const owner = await db.user.findUnique({
@@ -49,7 +80,16 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
select: { id: true, name: true, email: true, image: true }, select: { id: true, name: true, email: true, image: true },
}); });
const response = successResponse({ members, owner }); const response = successResponse(
{ members, owner },
200,
{
page,
limit,
total,
totalPages: Math.ceil(total / limit),
}
);
return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120'); return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120');
} catch (error) { } catch (error) {
console.error('Error fetching workspace members:', error); console.error('Error fetching workspace members:', error);
+20
View File
@@ -35,11 +35,29 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
try { try {
const session = await auth(); const session = await auth();
const { workspaceId } = await params; const { workspaceId } = await params;
const MAX_LIMIT = 100;
const MAX_OFFSET = 10000;
if (!session?.user?.id) { if (!session?.user?.id) {
return apiErrors.unauthorized(); return apiErrors.unauthorized();
} }
const searchParams = request.nextUrl.searchParams;
const limitParam = searchParams.get('limit');
const offsetParam = searchParams.get('offset');
const limitRaw = limitParam === null ? 20 : Number(limitParam);
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
}
const offset = offsetParam === null ? 0 : Number(offsetParam);
if (!Number.isSafeInteger(offset) || offset < 0 || offset > MAX_OFFSET) {
return apiErrors.badRequest('Invalid offset. Must be a non-negative integer up to 10000.');
}
const limit = limitRaw;
const workspace = await db.workspace.findUnique({ const workspace = await db.workspace.findUnique({
where: { id: workspaceId }, where: { id: workspaceId },
include: { include: {
@@ -51,6 +69,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
}, },
projects: { projects: {
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
skip: offset,
take: limit,
include: { include: {
_count: { select: { videos: true, members: true } }, _count: { select: { videos: true, members: true } },
}, },
+56 -16
View File
@@ -5,30 +5,70 @@ import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
// GET /api/workspaces - List all workspaces for the authenticated user // GET /api/workspaces - List all workspaces for the authenticated user
export async function GET() { export async function GET(request: NextRequest) {
try { try {
const session = await auth(); const session = await auth();
const MAX_LIMIT = 100;
const MAX_PAGE = 1000;
const MAX_OFFSET = 10000;
if (!session?.user?.id) { if (!session?.user?.id) {
return apiErrors.unauthorized(); return apiErrors.unauthorized();
} }
// Get workspaces where user is owner OR a member const searchParams = request.nextUrl.searchParams;
const workspaces = await db.workspace.findMany({ const pageParam = searchParams.get('page');
where: { const limitParam = searchParams.get('limit');
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id } } },
],
},
include: {
owner: { select: { id: true, name: true, image: true } },
_count: { select: { projects: true, members: true } },
},
orderBy: { updatedAt: 'desc' },
});
const response = successResponse({ workspaces }); const pageRaw = pageParam === null ? 1 : Number(pageParam);
if (!Number.isSafeInteger(pageRaw) || pageRaw < 1 || pageRaw > MAX_PAGE) {
return apiErrors.badRequest('Invalid page. Must be a positive integer.');
}
const limitRaw = limitParam === null ? 20 : Number(limitParam);
if (!Number.isSafeInteger(limitRaw) || limitRaw < 1 || limitRaw > MAX_LIMIT) {
return apiErrors.badRequest('Invalid limit. Must be a positive integer between 1 and 100.');
}
const page = pageRaw;
const limit = limitRaw;
const skip = (page - 1) * limit;
if (!Number.isSafeInteger(skip) || skip > MAX_OFFSET) {
return apiErrors.badRequest('Invalid page range. Offset must be 10000 or less.');
}
const where = {
OR: [
{ ownerId: session.user.id },
{ members: { some: { userId: session.user.id } } },
],
};
// Get workspaces where user is owner OR a member
const [workspaces, total] = await Promise.all([
db.workspace.findMany({
where,
include: {
owner: { select: { id: true, name: true, image: true } },
_count: { select: { projects: true, members: true } },
},
orderBy: { updatedAt: 'desc' },
skip,
take: limit,
}),
db.workspace.count({ where }),
]);
const response = successResponse(
{ workspaces },
200,
{
page,
limit,
total,
totalPages: Math.ceil(total / limit),
}
);
return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120'); return withCacheControl(response, 'private, max-age=60, stale-while-revalidate=120');
} catch (error) { } catch (error) {
console.error('Error fetching workspaces:', error); console.error('Error fetching workspaces:', error);