Integrate Prisma for content; enhance SEO, i18n, and deployment workflows
Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
@@ -1,37 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma, projectService } from '@/lib/prisma';
|
||||
import { requireSessionAuth } from '@/lib/auth';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma, projectService } from "@/lib/prisma";
|
||||
import { requireSessionAuth } from "@/lib/auth";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
type ImportSiteSettings = {
|
||||
defaultLocale?: unknown;
|
||||
locales?: unknown;
|
||||
theme?: unknown;
|
||||
};
|
||||
|
||||
type ImportContentPageTranslation = {
|
||||
locale?: unknown;
|
||||
title?: unknown;
|
||||
slug?: unknown;
|
||||
content?: unknown;
|
||||
metaDescription?: unknown;
|
||||
keywords?: unknown;
|
||||
};
|
||||
|
||||
type ImportContentPage = {
|
||||
key?: unknown;
|
||||
status?: unknown;
|
||||
translations?: unknown;
|
||||
};
|
||||
|
||||
type ImportProject = {
|
||||
id?: unknown;
|
||||
slug?: unknown;
|
||||
defaultLocale?: unknown;
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
content?: unknown;
|
||||
tags?: unknown;
|
||||
category?: unknown;
|
||||
featured?: unknown;
|
||||
github?: unknown;
|
||||
live?: unknown;
|
||||
published?: unknown;
|
||||
imageUrl?: unknown;
|
||||
difficulty?: unknown;
|
||||
timeToComplete?: unknown;
|
||||
technologies?: unknown;
|
||||
challenges?: unknown;
|
||||
lessonsLearned?: unknown;
|
||||
futureImprovements?: unknown;
|
||||
demoVideo?: unknown;
|
||||
screenshots?: unknown;
|
||||
colorScheme?: unknown;
|
||||
accessibility?: unknown;
|
||||
performance?: unknown;
|
||||
analytics?: unknown;
|
||||
};
|
||||
|
||||
type ImportProjectTranslation = {
|
||||
projectId?: unknown;
|
||||
locale?: unknown;
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
content?: unknown;
|
||||
metaDescription?: unknown;
|
||||
keywords?: unknown;
|
||||
ogImage?: unknown;
|
||||
schema?: unknown;
|
||||
};
|
||||
|
||||
type ImportPayload = {
|
||||
projects?: unknown;
|
||||
siteSettings?: unknown;
|
||||
contentPages?: unknown;
|
||||
projectTranslations?: unknown;
|
||||
};
|
||||
|
||||
function asString(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] | null {
|
||||
if (!Array.isArray(v)) return null;
|
||||
const allStrings = v.filter((x) => typeof x === "string") as string[];
|
||||
return allStrings.length === v.length ? allStrings : null;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
||||
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 body = await request.json();
|
||||
const body = (await request.json()) as ImportPayload;
|
||||
|
||||
// Validate import data structure
|
||||
if (!body.projects || !Array.isArray(body.projects)) {
|
||||
if (!Array.isArray(body.projects)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid import data format' },
|
||||
{ status: 400 }
|
||||
{ error: "Invalid import data format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const results = {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
errors: [] as string[]
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
// Import SiteSettings (optional)
|
||||
if (body.siteSettings && typeof body.siteSettings === 'object') {
|
||||
if (body.siteSettings && typeof body.siteSettings === "object") {
|
||||
try {
|
||||
const ss = body.siteSettings as ImportSiteSettings;
|
||||
const defaultLocale = asString(ss.defaultLocale);
|
||||
const locales = asStringArray(ss.locales);
|
||||
const theme = ss.theme as Prisma.InputJsonValue | undefined;
|
||||
|
||||
await prisma.siteSettings.upsert({
|
||||
where: { id: 1 },
|
||||
create: { id: 1, ...(body.siteSettings as Record<string, unknown>) } as any,
|
||||
update: { ...(body.siteSettings as Record<string, unknown>) } as any,
|
||||
create: {
|
||||
id: 1,
|
||||
...(defaultLocale ? { defaultLocale } : {}),
|
||||
...(locales ? { locales } : {}),
|
||||
...(theme ? { theme } : {}),
|
||||
},
|
||||
update: {
|
||||
...(defaultLocale ? { defaultLocale } : {}),
|
||||
...(locales ? { locales } : {}),
|
||||
...(theme ? { theme } : {}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// non-blocking
|
||||
@@ -42,39 +137,47 @@ export async function POST(request: NextRequest) {
|
||||
if (Array.isArray(body.contentPages)) {
|
||||
for (const page of body.contentPages) {
|
||||
try {
|
||||
if (!page?.key) continue;
|
||||
const key = asString((page as ImportContentPage)?.key);
|
||||
if (!key) continue;
|
||||
const statusRaw = asString((page as ImportContentPage)?.status);
|
||||
const status = statusRaw === "DRAFT" || statusRaw === "PUBLISHED" ? statusRaw : "PUBLISHED";
|
||||
const upserted = await prisma.contentPage.upsert({
|
||||
where: { key: page.key },
|
||||
create: { key: page.key, status: page.status || 'PUBLISHED' },
|
||||
update: { status: page.status || 'PUBLISHED' },
|
||||
where: { key },
|
||||
create: { key, status },
|
||||
update: { status },
|
||||
});
|
||||
|
||||
if (Array.isArray(page.translations)) {
|
||||
for (const tr of page.translations) {
|
||||
if (!tr?.locale || !tr?.content) continue;
|
||||
const translations = (page as ImportContentPage)?.translations;
|
||||
if (Array.isArray(translations)) {
|
||||
for (const tr of translations as ImportContentPageTranslation[]) {
|
||||
const locale = asString(tr?.locale);
|
||||
if (!locale || typeof tr?.content === "undefined" || tr?.content === null) continue;
|
||||
await prisma.contentPageTranslation.upsert({
|
||||
where: { pageId_locale: { pageId: upserted.id, locale: tr.locale } },
|
||||
where: { pageId_locale: { pageId: upserted.id, locale } },
|
||||
create: {
|
||||
pageId: upserted.id,
|
||||
locale: tr.locale,
|
||||
title: tr.title || null,
|
||||
slug: tr.slug || null,
|
||||
content: tr.content,
|
||||
metaDescription: tr.metaDescription || null,
|
||||
keywords: tr.keywords || null,
|
||||
} as any,
|
||||
locale,
|
||||
title: asString(tr.title),
|
||||
slug: asString(tr.slug),
|
||||
content: tr.content as Prisma.InputJsonValue,
|
||||
metaDescription: asString(tr.metaDescription),
|
||||
keywords: asString(tr.keywords),
|
||||
},
|
||||
update: {
|
||||
title: tr.title || null,
|
||||
slug: tr.slug || null,
|
||||
content: tr.content,
|
||||
metaDescription: tr.metaDescription || null,
|
||||
keywords: tr.keywords || null,
|
||||
} as any,
|
||||
title: asString(tr.title),
|
||||
slug: asString(tr.slug),
|
||||
content: tr.content as Prisma.InputJsonValue,
|
||||
metaDescription: asString(tr.metaDescription),
|
||||
keywords: asString(tr.keywords),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push(`Failed to import content page "${page?.key}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
const key = asString((page as ImportContentPage)?.key) ?? "unknown";
|
||||
results.errors.push(
|
||||
`Failed to import content page "${key}": ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,102 +186,124 @@ export async function POST(request: NextRequest) {
|
||||
const existingProjectsResult = await projectService.getAllProjects({ limit: 10000 });
|
||||
const existingProjects = existingProjectsResult.projects || existingProjectsResult;
|
||||
const existingTitles = new Set(existingProjects.map(p => p.title));
|
||||
const existingSlugs = new Set(existingProjects.map(p => (p as unknown as { slug?: string }).slug).filter(Boolean));
|
||||
const existingSlugs = new Set(
|
||||
existingProjects
|
||||
.map((p) => (p as unknown as { slug?: string }).slug)
|
||||
.filter((s): s is string => typeof s === "string" && s.length > 0),
|
||||
);
|
||||
|
||||
// Process each project
|
||||
for (const projectData of body.projects) {
|
||||
for (const projectData of body.projects as ImportProject[]) {
|
||||
try {
|
||||
// Check if project already exists (by title)
|
||||
const exists = existingTitles.has(projectData.title);
|
||||
const title = asString(projectData.title);
|
||||
if (!title) continue;
|
||||
const exists = existingTitles.has(title);
|
||||
|
||||
if (exists) {
|
||||
results.skipped++;
|
||||
results.errors.push(`Project "${projectData.title}" already exists`);
|
||||
results.errors.push(`Project "${title}" already exists`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create new project
|
||||
const created = await projectService.createProject({
|
||||
slug: projectData.slug,
|
||||
defaultLocale: projectData.defaultLocale || 'en',
|
||||
title: projectData.title,
|
||||
description: projectData.description,
|
||||
content: projectData.content,
|
||||
tags: projectData.tags || [],
|
||||
category: projectData.category,
|
||||
featured: projectData.featured || false,
|
||||
github: projectData.github,
|
||||
live: projectData.live,
|
||||
slug: asString(projectData.slug) ?? undefined,
|
||||
defaultLocale: asString(projectData.defaultLocale) ?? "en",
|
||||
title,
|
||||
description: asString(projectData.description) ?? "",
|
||||
content: projectData.content as Prisma.InputJsonValue | undefined,
|
||||
tags: (asStringArray(projectData.tags) ?? []) as string[],
|
||||
category: asString(projectData.category) ?? "General",
|
||||
featured: projectData.featured === true,
|
||||
github: asString(projectData.github) ?? undefined,
|
||||
live: asString(projectData.live) ?? undefined,
|
||||
published: projectData.published !== false, // Default to true
|
||||
imageUrl: projectData.imageUrl,
|
||||
difficulty: projectData.difficulty || 'Intermediate',
|
||||
timeToComplete: projectData.timeToComplete,
|
||||
technologies: projectData.technologies || [],
|
||||
challenges: projectData.challenges || [],
|
||||
lessonsLearned: projectData.lessonsLearned || [],
|
||||
futureImprovements: projectData.futureImprovements || [],
|
||||
demoVideo: projectData.demoVideo,
|
||||
screenshots: projectData.screenshots || [],
|
||||
colorScheme: projectData.colorScheme || 'Dark',
|
||||
imageUrl: asString(projectData.imageUrl) ?? undefined,
|
||||
difficulty: asString(projectData.difficulty) ?? "Intermediate",
|
||||
timeToComplete: asString(projectData.timeToComplete) ?? undefined,
|
||||
technologies: (asStringArray(projectData.technologies) ?? []) as string[],
|
||||
challenges: (asStringArray(projectData.challenges) ?? []) as string[],
|
||||
lessonsLearned: (asStringArray(projectData.lessonsLearned) ?? []) as string[],
|
||||
futureImprovements: (asStringArray(projectData.futureImprovements) ?? []) as string[],
|
||||
demoVideo: asString(projectData.demoVideo) ?? undefined,
|
||||
screenshots: (asStringArray(projectData.screenshots) ?? []) as string[],
|
||||
colorScheme: asString(projectData.colorScheme) ?? "Dark",
|
||||
accessibility: projectData.accessibility !== false, // Default to true
|
||||
performance: projectData.performance || {
|
||||
performance: (projectData.performance as Record<string, unknown> | null) || {
|
||||
lighthouse: 0,
|
||||
bundleSize: '0KB',
|
||||
loadTime: '0s'
|
||||
bundleSize: "0KB",
|
||||
loadTime: "0s",
|
||||
},
|
||||
analytics: projectData.analytics || {
|
||||
analytics: (projectData.analytics as Record<string, unknown> | null) || {
|
||||
views: 0,
|
||||
likes: 0,
|
||||
shares: 0
|
||||
}
|
||||
shares: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Import translations (optional, from export v2)
|
||||
if (Array.isArray(body.projectTranslations)) {
|
||||
for (const tr of body.projectTranslations) {
|
||||
if (!tr?.projectId || !tr?.locale) continue;
|
||||
for (const tr of body.projectTranslations as ImportProjectTranslation[]) {
|
||||
const projectId = typeof tr?.projectId === "number" ? tr.projectId : null;
|
||||
const locale = asString(tr?.locale);
|
||||
if (!projectId || !locale) continue;
|
||||
// Map translation to created project by original slug/title when possible.
|
||||
// We match by slug if available in exported project list; otherwise by title.
|
||||
const exportedProject = body.projects.find((p: any) => p.id === tr.projectId);
|
||||
const exportedSlug = exportedProject?.slug;
|
||||
const exportedProject = (body.projects as ImportProject[]).find(
|
||||
(p) => typeof p.id === "number" && p.id === projectId,
|
||||
);
|
||||
const exportedSlug = asString(exportedProject?.slug);
|
||||
const matches =
|
||||
(exportedSlug && (created as any).slug === exportedSlug) ||
|
||||
(!!exportedProject?.title && (created as any).title === exportedProject.title);
|
||||
(exportedSlug && (created as unknown as { slug?: string }).slug === exportedSlug) ||
|
||||
(!!asString(exportedProject?.title) &&
|
||||
(created as unknown as { title?: string }).title === asString(exportedProject?.title));
|
||||
if (!matches) continue;
|
||||
|
||||
if (!tr.title || !tr.description) continue;
|
||||
const trTitle = asString(tr.title);
|
||||
const trDescription = asString(tr.description);
|
||||
if (!trTitle || !trDescription) continue;
|
||||
await prisma.projectTranslation.upsert({
|
||||
where: { projectId_locale: { projectId: (created as any).id, locale: tr.locale } },
|
||||
where: {
|
||||
projectId_locale: {
|
||||
projectId: (created as unknown as { id: number }).id,
|
||||
locale,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
projectId: (created as any).id,
|
||||
locale: tr.locale,
|
||||
title: tr.title,
|
||||
description: tr.description,
|
||||
content: tr.content || null,
|
||||
metaDescription: tr.metaDescription || null,
|
||||
keywords: tr.keywords || null,
|
||||
ogImage: tr.ogImage || null,
|
||||
schema: tr.schema || null,
|
||||
} as any,
|
||||
projectId: (created as unknown as { id: number }).id,
|
||||
locale,
|
||||
title: trTitle,
|
||||
description: trDescription,
|
||||
content: (tr.content as Prisma.InputJsonValue) ?? null,
|
||||
metaDescription: asString(tr.metaDescription),
|
||||
keywords: asString(tr.keywords),
|
||||
ogImage: asString(tr.ogImage),
|
||||
schema: (tr.schema as Prisma.InputJsonValue) ?? null,
|
||||
},
|
||||
update: {
|
||||
title: tr.title,
|
||||
description: tr.description,
|
||||
content: tr.content || null,
|
||||
metaDescription: tr.metaDescription || null,
|
||||
keywords: tr.keywords || null,
|
||||
ogImage: tr.ogImage || null,
|
||||
schema: tr.schema || null,
|
||||
} as any,
|
||||
title: trTitle,
|
||||
description: trDescription,
|
||||
content: (tr.content as Prisma.InputJsonValue) ?? null,
|
||||
metaDescription: asString(tr.metaDescription),
|
||||
keywords: asString(tr.keywords),
|
||||
ogImage: asString(tr.ogImage),
|
||||
schema: (tr.schema as Prisma.InputJsonValue) ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.imported++;
|
||||
existingTitles.add(projectData.title);
|
||||
if (projectData.slug) existingSlugs.add(projectData.slug);
|
||||
existingTitles.add(title);
|
||||
const slug = asString(projectData.slug);
|
||||
if (slug) existingSlugs.add(slug);
|
||||
} catch (error) {
|
||||
results.skipped++;
|
||||
results.errors.push(`Failed to import "${projectData.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
const title = asString(projectData.title) ?? "unknown";
|
||||
results.errors.push(
|
||||
`Failed to import "${title}": ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,10 +313,10 @@ export async function POST(request: NextRequest) {
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
console.error("Import error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to import projects' },
|
||||
{ status: 500 }
|
||||
{ error: "Failed to import projects" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user