@jjdenhertog/ai-driven-development
Version:
AI-driven development workflow with learning capabilities for Claude
350 lines (299 loc) • 9.82 kB
text/typescript
// app/api/products/route.ts
// Example API route following your preferences: Zod validation, Redis caching, proper error handling
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { redis } from '@/lib/redis';
import { z } from 'zod';
import * as Sentry from '@sentry/nextjs';
// Zod schemas for validation (always define before component/handler)
const createProductSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
price: z.number().positive('Price must be positive'),
stock: z.number().int().min(0, 'Stock cannot be negative'),
category: z.enum(['electronics', 'clothing', 'food', 'books', 'other']),
tags: z.array(z.string()).optional(),
active: z.boolean().default(true),
});
const updateProductSchema = createProductSchema.partial();
const queryParamsSchema = z.object({
page: z.coerce.number().int().min(0).default(0),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
category: z.enum(['electronics', 'clothing', 'food', 'books', 'other']).optional(),
sortBy: z.enum(['name', 'price', 'createdAt']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
// Type inference from schemas
type CreateProductInput = z.infer<typeof createProductSchema>;
type UpdateProductInput = z.infer<typeof updateProductSchema>;
type QueryParams = z.infer<typeof queryParamsSchema>;
// Custom error handler following your patterns
function handleError(error: unknown): NextResponse {
console.error('API Error:', error);
// Sentry integration
Sentry.withScope((scope) => {
scope.setLevel('error');
scope.setContext('error_details', {
type: error?.constructor?.name,
message: error instanceof Error ? error.message : 'Unknown error',
});
Sentry.captureException(error);
});
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: error.errors },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json(
{ error: error.message },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
// Helper for rate limiting (following your Redis patterns)
async function checkRateLimit(identifier: string): Promise<{ success: boolean; remaining: number }> {
const key = `rate_limit:api:products:${identifier}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 60); // 1 minute window
}
const limit = 30; // 30 requests per minute
return {
success: count <= limit,
remaining: Math.max(0, limit - count),
};
}
// Caching helper (following your Redis patterns)
async function getCachedData<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 300 // 5 minutes default
): Promise<T> {
try {
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
} catch (error) {
console.warn('Cache read error:', error);
}
const data = await fetcher();
try {
await redis.setex(key, ttl, JSON.stringify(data));
} catch (error) {
console.warn('Cache write error:', error);
}
return data;
}
// Cache invalidation helper
async function invalidateProductCache(productId?: string) {
const keys: string[] = ['products:*'];
if (productId) {
keys.push(`product:${productId}`);
}
for (const pattern of keys) {
const matchingKeys = await redis.keys(pattern);
if (matchingKeys.length > 0) {
await redis.del(...matchingKeys);
}
}
}
// GET /api/products - List products with caching and pagination
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Parse and validate query parameters
const { searchParams } = new URL(request.url);
const params = queryParamsSchema.parse({
page: searchParams.get('page'),
pageSize: searchParams.get('pageSize'),
search: searchParams.get('search'),
category: searchParams.get('category'),
sortBy: searchParams.get('sortBy'),
sortOrder: searchParams.get('sortOrder'),
});
// Build cache key
const cacheKey = `products:${JSON.stringify(params)}`;
// Try to get from cache
const cachedResult = await getCachedData(
cacheKey,
async () => {
// Build where clause
const where: any = { active: true };
if (params.search) {
where.OR = [
{ name: { contains: params.search, mode: 'insensitive' } },
{ description: { contains: params.search, mode: 'insensitive' } },
];
}
if (params.category) {
where.category = params.category;
}
// Execute queries in parallel
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
take: params.pageSize,
skip: params.page * params.pageSize,
orderBy: { [params.sortBy]: params.sortOrder },
select: {
id: true,
name: true,
description: true,
price: true,
stock: true,
category: true,
tags: true,
active: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.product.count({ where }),
]);
return {
products,
pagination: {
page: params.page,
pageSize: params.pageSize,
total,
totalPages: Math.ceil(total / params.pageSize),
},
};
},
300 // 5 minutes cache
);
return NextResponse.json(cachedResult);
} catch (error) {
return handleError(error);
}
}
// POST /api/products - Create new product
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Rate limiting
const { success, remaining } = await checkRateLimit(session.user.id);
if (!success) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{
status: 429,
headers: {
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': new Date(Date.now() + 60000).toISOString(),
},
}
);
}
// Only admins can create products
if (session.user.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const validatedData = createProductSchema.parse(body);
// Check for duplicate name
const existing = await prisma.product.findFirst({
where: {
name: validatedData.name,
active: true,
},
});
if (existing) {
return NextResponse.json(
{ error: 'Product with this name already exists' },
{ status: 409 }
);
}
// Create product with audit fields
const product = await prisma.product.create({
data: {
...validatedData,
createdBy: session.user.id,
updatedBy: session.user.id,
},
});
// Invalidate cache
await invalidateProductCache();
// Log activity (following your patterns)
await prisma.activityLog.create({
data: {
userId: session.user.id,
action: 'CREATE_PRODUCT',
entityType: 'product',
entityId: product.id,
metadata: { productName: product.name },
},
});
return NextResponse.json(product, { status: 201 });
} catch (error) {
return handleError(error);
}
}
// PUT /api/products - Bulk update products
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Only admins can bulk update
if (session.user.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
// Validate bulk update data
const bulkUpdateSchema = z.object({
ids: z.array(z.string()).min(1, 'At least one ID required'),
data: updateProductSchema,
});
const { ids, data } = bulkUpdateSchema.parse(body);
// Use transaction for bulk update
const updated = await prisma.$transaction(async (tx) => {
// Verify all products exist
const products = await tx.product.findMany({
where: { id: { in: ids } },
});
if (products.length !== ids.length) {
throw new Error('Some products not found');
}
// Update all products
const updatePromises = ids.map(id =>
tx.product.update({
where: { id },
data: {
...data,
updatedBy: session.user.id,
updatedAt: new Date(),
},
})
);
return Promise.all(updatePromises);
});
// Invalidate cache
await invalidateProductCache();
return NextResponse.json({
updated: updated.length,
products: updated,
});
} catch (error) {
return handleError(error);
}
}
// Example of a specific product route handler
// app/api/products/[id]/route.ts would handle individual product operations