Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Merged Directus and PostgreSQL project data, implemented single project fetch from CMS, and modernized the NotFound component with liquid design.
238 lines
7.8 KiB
TypeScript
238 lines
7.8 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { prisma } from '@/lib/prisma';
|
|
import { apiCache } from '@/lib/cache';
|
|
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders, getClientIp } from '@/lib/auth';
|
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
|
import { generateUniqueSlug } from '@/lib/slug';
|
|
import { getProjects as getDirectusProjects } from '@/lib/directus';
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
// Rate limiting
|
|
const ip = getClientIp(request);
|
|
const rlKey = ip !== "unknown" ? ip : `dev_unknown:${request.headers.get("user-agent") || "ua"}`;
|
|
// In development we keep this very high to avoid breaking local navigation/HMR.
|
|
const max = process.env.NODE_ENV === "development" ? 300 : 60;
|
|
if (!checkRateLimit(rlKey, max, 60000)) {
|
|
return new NextResponse(
|
|
JSON.stringify({ error: 'Rate limit exceeded' }),
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getRateLimitHeaders(rlKey, max, 60000)
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// Check session authentication for admin endpoints
|
|
const url = new URL(request.url);
|
|
if (url.pathname.includes('/manage') || request.headers.get('x-admin-request') === 'true') {
|
|
const authError = requireSessionAuth(request);
|
|
if (authError) {
|
|
return authError;
|
|
}
|
|
}
|
|
const { searchParams } = new URL(request.url);
|
|
const pageRaw = parseInt(searchParams.get('page') || '1');
|
|
const limitRaw = parseInt(searchParams.get('limit') || '50');
|
|
const page = Number.isFinite(pageRaw) && pageRaw > 0 ? pageRaw : 1;
|
|
const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 200 ? limitRaw : 50;
|
|
const category = searchParams.get('category');
|
|
const featured = searchParams.get('featured');
|
|
const published = searchParams.get('published');
|
|
const difficulty = searchParams.get('difficulty');
|
|
const search = searchParams.get('search');
|
|
const locale = searchParams.get('locale') || 'en';
|
|
|
|
// Try Directus FIRST (Primary Source)
|
|
let directusProjects: any[] = [];
|
|
try {
|
|
const fetched = await getDirectusProjects(locale, {
|
|
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
|
|
published: published === 'true' ? true : published === 'false' ? false : undefined,
|
|
category: category || undefined,
|
|
difficulty: difficulty || undefined,
|
|
search: search || undefined,
|
|
limit
|
|
});
|
|
|
|
if (fetched) {
|
|
directusProjects = fetched;
|
|
}
|
|
} catch (directusError) {
|
|
console.log('Directus error, continuing with PostgreSQL');
|
|
}
|
|
|
|
// Fallback 1: Try PostgreSQL
|
|
try {
|
|
await prisma.$queryRaw`SELECT 1`;
|
|
} catch (dbError) {
|
|
console.log('PostgreSQL not available');
|
|
if (directusProjects.length > 0) {
|
|
return NextResponse.json({
|
|
projects: directusProjects,
|
|
total: directusProjects.length,
|
|
source: 'directus'
|
|
});
|
|
}
|
|
|
|
return NextResponse.json({
|
|
projects: [],
|
|
total: 0,
|
|
source: 'fallback'
|
|
});
|
|
}
|
|
|
|
const skip = (page - 1) * limit;
|
|
const where: Record<string, unknown> = {};
|
|
|
|
if (category) where.category = category;
|
|
if (featured !== null) where.featured = featured === 'true';
|
|
if (published !== null) where.published = published === 'true';
|
|
if (difficulty) where.difficulty = difficulty;
|
|
|
|
if (search) {
|
|
where.OR = [
|
|
{ title: { contains: search, mode: 'insensitive' } },
|
|
{ description: { contains: search, mode: 'insensitive' } },
|
|
{ tags: { hasSome: [search] } }
|
|
];
|
|
}
|
|
|
|
const [dbProjects, total] = await Promise.all([
|
|
prisma.project.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
skip,
|
|
take: limit
|
|
}),
|
|
prisma.project.count({ where })
|
|
]);
|
|
|
|
// Merge logic
|
|
const dbSlugs = new Set(dbProjects.map(p => p.slug));
|
|
const mergedProjects = [...dbProjects];
|
|
|
|
for (const dp of directusProjects) {
|
|
if (!dbSlugs.has(dp.slug)) {
|
|
mergedProjects.push(dp);
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
projects: mergedProjects,
|
|
total: total + (mergedProjects.length - dbProjects.length),
|
|
source: 'merged'
|
|
});
|
|
} catch (error) {
|
|
// Handle missing database table gracefully
|
|
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('Project table does not exist. Returning empty result.');
|
|
}
|
|
return NextResponse.json({
|
|
projects: [],
|
|
total: 0,
|
|
pages: 0,
|
|
currentPage: 1
|
|
});
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error('Error fetching projects:', error);
|
|
}
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch projects' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
// Rate limiting for POST requests
|
|
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
|
if (!checkRateLimit(ip, 5, 60000)) { // 5 requests per minute for POST
|
|
return new NextResponse(
|
|
JSON.stringify({ error: 'Rate limit exceeded' }),
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...getRateLimitHeaders(ip, 5, 60000)
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// Check if this is an admin request
|
|
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
|
if (!isAdminRequest) {
|
|
return NextResponse.json(
|
|
{ error: 'Admin access required' },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
const authError = requireSessionAuth(request);
|
|
if (authError) return authError;
|
|
|
|
const data = await request.json();
|
|
|
|
// Remove difficulty field if it exists (since we're removing it)
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { difficulty, slug, defaultLocale, ...projectData } = data;
|
|
|
|
const derivedSlug =
|
|
typeof slug === 'string' && slug.trim()
|
|
? slug.trim()
|
|
: await generateUniqueSlug({
|
|
base: String(projectData.title || 'project'),
|
|
isTaken: async (candidate) => {
|
|
const existing = await prisma.project.findUnique({
|
|
where: { slug: candidate },
|
|
select: { id: true },
|
|
});
|
|
return !!existing;
|
|
},
|
|
});
|
|
|
|
const project = await prisma.project.create({
|
|
data: {
|
|
...projectData,
|
|
slug: derivedSlug,
|
|
defaultLocale: typeof defaultLocale === 'string' && defaultLocale ? defaultLocale : undefined,
|
|
// Set default difficulty since it's required in schema
|
|
difficulty: 'INTERMEDIATE',
|
|
performance: data.performance || { lighthouse: 0, bundleSize: '0KB', loadTime: '0s' },
|
|
analytics: data.analytics || { views: 0, likes: 0, shares: 0 }
|
|
}
|
|
});
|
|
|
|
// Invalidate cache
|
|
await apiCache.invalidateAll();
|
|
|
|
return NextResponse.json(project);
|
|
} catch (error) {
|
|
// Handle missing database table gracefully
|
|
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.warn('Project table does not exist.');
|
|
}
|
|
return NextResponse.json(
|
|
{ error: 'Database table not found. Please run migrations.' },
|
|
{ status: 503 }
|
|
);
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error('Error creating project:', error);
|
|
}
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create project', details: error instanceof Error ? error.message : 'Unknown error' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|