Integrate Prisma for content; enhance SEO, i18n, and deployment workflows

Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
Cursor Agent
2026-01-12 15:27:35 +00:00
parent f1cc398248
commit 423a2af938
38 changed files with 757 additions and 629 deletions

View File

@@ -108,6 +108,7 @@ jobs:
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}

View File

@@ -69,6 +69,7 @@ jobs:
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}"
# Start new container with updated image (docker-compose will handle this)
echo "🆕 Starting new production container..."
@@ -202,6 +203,7 @@ jobs:
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }}

View File

@@ -25,6 +25,7 @@ Für den `production` Branch brauchst du:
- `MY_PASSWORD` = Dein Email-Passwort
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
- `ADMIN_BASIC_AUTH` = `admin:dein_sicheres_passwort`
- `ADMIN_SESSION_SECRET` = zufälliger Secret (mind. 32 Zeichen)
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
## 🧪 Variablen für Dev Branch
@@ -42,6 +43,7 @@ Für den `testing` Branch brauchst du die **gleichen** Variablen, aber mit ander
- `MY_PASSWORD` = Dein Email-Passwort (kann gleich sein)
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort (kann gleich sein)
- `ADMIN_BASIC_AUTH` = `admin:testing_password` (kann anders sein)
- `ADMIN_SESSION_SECRET` = zufälliger Secret (mind. 32 Zeichen; kann gleich sein)
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
## ✅ Lösung: Automatische Branch-Erkennung
@@ -89,6 +91,7 @@ Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch!
- `MY_PASSWORD` = Dein Email-Passwort
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
- `ADMIN_BASIC_AUTH` = `admin:dein_passwort`
- `ADMIN_SESSION_SECRET` = zufälliger Secret (mind. 32 Zeichen)
- `N8N_SECRET_TOKEN` = Dein n8n Token (optional)
**Optional setzen:**

View File

@@ -1,2 +1,19 @@
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export { default } from "../../legal-notice/page";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "legal-notice" });
return {
alternates: {
canonical: toAbsoluteUrl(`/${locale}/legal-notice`),
languages,
},
};
}

View File

@@ -1,2 +1,23 @@
export { default } from "../_ui/HomePage";
import type { Metadata } from "next";
import HomePage from "../_ui/HomePage";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "" });
return {
alternates: {
canonical: toAbsoluteUrl(`/${locale}`),
languages,
},
};
}
export default function Page() {
return <HomePage />;
}

View File

@@ -1,2 +1,19 @@
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export { default } from "../../privacy-policy/page";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "privacy-policy" });
return {
alternates: {
canonical: toAbsoluteUrl(`/${locale}/privacy-policy`),
languages,
},
};
}

View File

@@ -1,9 +1,26 @@
import { prisma } from "@/lib/prisma";
import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export const revalidate = 300;
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> {
const { locale, slug } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
return {
alternates: {
canonical: toAbsoluteUrl(`/${locale}/projects/${slug}`),
languages,
},
};
}
export default async function ProjectPage({
params,
}: {

View File

@@ -1,8 +1,25 @@
import { prisma } from "@/lib/prisma";
import ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export const revalidate = 300;
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "projects" });
return {
alternates: {
canonical: toAbsoluteUrl(`/${locale}/projects`),
languages,
},
};
}
export default async function ProjectsPage({
params,
}: {

View File

@@ -1,43 +1,27 @@
import { GET } from '@/app/api/fetchAllProjects/route';
import { NextResponse } from 'next/server';
// Wir mocken node-fetch direkt
jest.mock('node-fetch', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
posts: [
{
id: '67ac8dfa709c60000117d312',
title: 'Just Doing Some Testing',
meta_description: 'Hello bla bla bla bla',
slug: 'just-doing-some-testing',
updated_at: '2025-02-13T14:25:38.000+00:00',
},
{
id: '67aaffc3709c60000117d2d9',
title: 'Blockchain Based Voting System',
meta_description:
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
slug: 'blockchain-based-voting-system',
updated_at: '2025-02-13T16:54:42.000+00:00',
},
],
meta: {
pagination: {
limit: 'all',
next: null,
page: 1,
pages: 1,
prev: null,
total: 2,
},
},
}),
})
),
jest.mock('@/lib/prisma', () => ({
prisma: {
project: {
findMany: jest.fn(async () => [
{
id: 1,
slug: 'just-doing-some-testing',
title: 'Just Doing Some Testing',
updatedAt: new Date('2025-02-13T14:25:38.000Z'),
metaDescription: 'Hello bla bla bla bla',
},
{
id: 2,
slug: 'blockchain-based-voting-system',
title: 'Blockchain Based Voting System',
updatedAt: new Date('2025-02-13T16:54:42.000Z'),
metaDescription:
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
},
]),
},
},
}));
jest.mock('next/server', () => ({
@@ -47,12 +31,8 @@ jest.mock('next/server', () => ({
}));
describe('GET /api/fetchAllProjects', () => {
beforeAll(() => {
process.env.GHOST_API_URL = 'http://localhost:2368';
process.env.GHOST_API_KEY = 'some-key';
});
it('should return a list of projects (partial match)', async () => {
const { GET } = await import('@/app/api/fetchAllProjects/route');
await GET();
// Den tatsächlichen Argumentwert extrahieren
@@ -61,11 +41,11 @@ describe('GET /api/fetchAllProjects', () => {
expect(responseArg).toMatchObject({
posts: expect.arrayContaining([
expect.objectContaining({
id: '67ac8dfa709c60000117d312',
id: '1',
title: 'Just Doing Some Testing',
}),
expect.objectContaining({
id: '67aaffc3709c60000117d2d9',
id: '2',
title: 'Blockchain Based Voting System',
}),
]),

View File

@@ -1,26 +1,23 @@
import { GET } from '@/app/api/fetchProject/route';
import { NextRequest, NextResponse } from 'next/server';
// Mock node-fetch so the route uses it as a reliable fallback
jest.mock('node-fetch', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
posts: [
{
id: '67aaffc3709c60000117d2d9',
title: 'Blockchain Based Voting System',
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
slug: 'blockchain-based-voting-system',
updated_at: '2025-02-13T16:54:42.000+00:00',
},
],
}),
})
),
jest.mock('@/lib/prisma', () => ({
prisma: {
project: {
findUnique: jest.fn(async ({ where }: { where: { slug: string } }) => {
if (where.slug !== 'blockchain-based-voting-system') return null;
return {
id: 2,
title: 'Blockchain Based Voting System',
metaDescription:
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
slug: 'blockchain-based-voting-system',
updatedAt: new Date('2025-02-13T16:54:42.000Z'),
description: null,
content: null,
};
}),
},
},
}));
jest.mock('next/server', () => ({
@@ -29,12 +26,8 @@ jest.mock('next/server', () => ({
},
}));
describe('GET /api/fetchProject', () => {
beforeAll(() => {
process.env.GHOST_API_URL = 'http://localhost:2368';
process.env.GHOST_API_KEY = 'some-key';
});
it('should fetch a project by slug', async () => {
const { GET } = await import('@/app/api/fetchProject/route');
const mockRequest = {
url: 'http://localhost/api/fetchProject?slug=blockchain-based-voting-system',
} as unknown as NextRequest;
@@ -44,11 +37,11 @@ describe('GET /api/fetchProject', () => {
expect(NextResponse.json).toHaveBeenCalledWith({
posts: [
{
id: '67aaffc3709c60000117d2d9',
id: '2',
title: 'Blockchain Based Voting System',
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
slug: 'blockchain-based-voting-system',
updated_at: '2025-02-13T16:54:42.000+00:00',
updated_at: '2025-02-13T16:54:42.000Z',
},
],
});

View File

@@ -34,77 +34,38 @@ jest.mock("next/server", () => {
};
});
import { GET } from "@/app/api/sitemap/route";
// Mock node-fetch so we don't perform real network requests in tests
jest.mock("node-fetch", () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
posts: [
{
id: "67ac8dfa709c60000117d312",
title: "Just Doing Some Testing",
meta_description: "Hello bla bla bla bla",
slug: "just-doing-some-testing",
updated_at: "2025-02-13T14:25:38.000+00:00",
},
{
id: "67aaffc3709c60000117d2d9",
title: "Blockchain Based Voting System",
meta_description:
"This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.",
slug: "blockchain-based-voting-system",
updated_at: "2025-02-13T16:54:42.000+00:00",
},
],
meta: {
pagination: {
limit: "all",
next: null,
page: 1,
pages: 1,
prev: null,
total: 2,
},
},
}),
}),
jest.mock("@/lib/sitemap", () => ({
getSitemapEntries: jest.fn(async () => [
{
url: "https://dki.one/en",
lastModified: "2025-01-01T00:00:00.000Z",
},
{
url: "https://dki.one/de",
lastModified: "2025-01-01T00:00:00.000Z",
},
{
url: "https://dki.one/en/projects/blockchain-based-voting-system",
lastModified: "2025-02-13T16:54:42.000Z",
},
{
url: "https://dki.one/de/projects/blockchain-based-voting-system",
lastModified: "2025-02-13T16:54:42.000Z",
},
]),
generateSitemapXml: jest.fn(
() =>
'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://dki.one/en</loc></url></urlset>',
),
}));
describe("GET /api/sitemap", () => {
beforeAll(() => {
process.env.GHOST_API_URL = "http://localhost:2368";
process.env.GHOST_API_KEY = "test-api-key";
process.env.NEXT_PUBLIC_BASE_URL = "https://dki.one";
// Provide mock posts via env so route can use them without fetching
process.env.GHOST_MOCK_POSTS = JSON.stringify({
posts: [
{
id: "67ac8dfa709c60000117d312",
title: "Just Doing Some Testing",
meta_description: "Hello bla bla bla bla",
slug: "just-doing-some-testing",
updated_at: "2025-02-13T14:25:38.000+00:00",
},
{
id: "67aaffc3709c60000117d2d9",
title: "Blockchain Based Voting System",
meta_description:
"This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.",
slug: "blockchain-based-voting-system",
updated_at: "2025-02-13T16:54:42.000+00:00",
},
],
});
});
it("should return a sitemap", async () => {
const { GET } = await import("@/app/api/sitemap/route");
const response = await GET();
// Get the body text from the NextResponse
@@ -113,15 +74,7 @@ describe("GET /api/sitemap", () => {
expect(body).toContain(
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">',
);
expect(body).toContain("<loc>https://dki.one/</loc>");
expect(body).toContain("<loc>https://dki.one/legal-notice</loc>");
expect(body).toContain("<loc>https://dki.one/privacy-policy</loc>");
expect(body).toContain(
"<loc>https://dki.one/projects/just-doing-some-testing</loc>",
);
expect(body).toContain(
"<loc>https://dki.one/projects/blockchain-based-voting-system</loc>",
);
expect(body).toContain("<loc>https://dki.one/en</loc>");
// Note: Headers are not available in test environment
});
});

View File

@@ -1,5 +1,4 @@
import "@testing-library/jest-dom";
import { GET } from "@/app/sitemap.xml/route";
jest.mock("next/server", () => ({
NextResponse: jest.fn().mockImplementation((body: unknown, init?: ResponseInit) => {
@@ -11,71 +10,32 @@ jest.mock("next/server", () => ({
}),
}));
// Sitemap XML used by node-fetch mock
const sitemapXml = `
<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://dki.one/</loc>
</url>
<url>
<loc>https://dki.one/legal-notice</loc>
</url>
<url>
<loc>https://dki.one/privacy-policy</loc>
</url>
<url>
<loc>https://dki.one/projects/just-doing-some-testing</loc>
</url>
<url>
<loc>https://dki.one/projects/blockchain-based-voting-system</loc>
</url>
</urlset>
`;
// Mock node-fetch for sitemap endpoint (hoisted by Jest)
jest.mock("node-fetch", () => ({
__esModule: true,
default: jest.fn((_url: string) =>
Promise.resolve({ ok: true, text: () => Promise.resolve(sitemapXml) }),
jest.mock("@/lib/sitemap", () => ({
getSitemapEntries: jest.fn(async () => [
{
url: "https://dki.one/en",
lastModified: "2025-01-01T00:00:00.000Z",
},
]),
generateSitemapXml: jest.fn(
() =>
'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://dki.one/en</loc></url></urlset>',
),
}));
describe("Sitemap Component", () => {
beforeAll(() => {
process.env.NEXT_PUBLIC_BASE_URL = "https://dki.one";
// Provide sitemap XML directly so route uses it without fetching
process.env.GHOST_MOCK_SITEMAP = sitemapXml;
// Mock global.fetch too, to avoid any network calls
global.fetch = jest.fn().mockImplementation((url: string) => {
if (url.includes("/api/sitemap")) {
return Promise.resolve({
ok: true,
text: () => Promise.resolve(sitemapXml),
});
}
return Promise.reject(new Error(`Unknown URL: ${url}`));
});
});
it("should render the sitemap XML", async () => {
const { GET } = await import("@/app/sitemap.xml/route");
const response = await GET();
expect(response.body).toContain(
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">',
);
expect(response.body).toContain("<loc>https://dki.one/</loc>");
expect(response.body).toContain("<loc>https://dki.one/legal-notice</loc>");
expect(response.body).toContain(
"<loc>https://dki.one/privacy-policy</loc>",
);
expect(response.body).toContain(
"<loc>https://dki.one/projects/just-doing-some-testing</loc>",
);
expect(response.body).toContain(
"<loc>https://dki.one/projects/blockchain-based-voting-system</loc>",
);
expect(response.body).toContain("<loc>https://dki.one/en</loc>");
// Note: Headers are not available in test environment
});
});

View File

@@ -1,66 +1,58 @@
import { NextResponse } from "next/server";
import NodeCache from "node-cache";
// Use a dynamic import for node-fetch so tests that mock it (via jest.mock) are respected
async function getFetch() {
try {
const mod = await import("node-fetch");
// support both CJS and ESM interop
return (mod as { default: unknown }).default ?? mod;
} catch (_err) {
return globalThis.fetch;
}
}
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs"; // Force Node runtime
const GHOST_API_URL = process.env.GHOST_API_URL;
const GHOST_API_KEY = process.env.GHOST_API_KEY;
const cache = new NodeCache({ stdTTL: 300 }); // Cache für 5 Minuten
type GhostPost = {
type LegacyPost = {
slug: string;
id: string;
title: string;
feature_image: string;
visibility: string;
published_at: string;
meta_description: string | null;
updated_at: string;
html: string;
reading_time: number;
meta_description: string;
};
type GhostPostsResponse = {
posts: Array<GhostPost>;
type LegacyPostsResponse = {
posts: Array<LegacyPost>;
};
export async function GET() {
const cacheKey = "ghostPosts";
const cachedPosts = cache.get<GhostPostsResponse>(cacheKey);
const cacheKey = "projects:legacyPosts";
const cachedPosts = cache.get<LegacyPostsResponse>(cacheKey);
if (cachedPosts) {
return NextResponse.json(cachedPosts);
}
try {
const fetchFn = await getFetch();
const response = await (fetchFn as unknown as typeof fetch)(
`${GHOST_API_URL}/ghost/api/content/posts/?key=${GHOST_API_KEY}&limit=all`,
);
const posts: GhostPostsResponse =
(await response.json()) as GhostPostsResponse;
const projects = await prisma.project.findMany({
where: { published: true },
orderBy: { updatedAt: "desc" },
select: {
id: true,
slug: true,
title: true,
updatedAt: true,
metaDescription: true,
},
});
if (!posts || !posts.posts) {
console.error("Invalid posts data");
return NextResponse.json([]);
}
const payload: LegacyPostsResponse = {
posts: projects.map((p) => ({
id: String(p.id),
slug: p.slug,
title: p.title,
meta_description: p.metaDescription ?? null,
updated_at: (p.updatedAt ?? new Date()).toISOString(),
})),
};
cache.set(cacheKey, posts); // Daten im Cache speichern
return NextResponse.json(posts);
cache.set(cacheKey, payload);
return NextResponse.json(payload);
} catch (error) {
console.error("Failed to fetch posts from Ghost:", error);
console.error("Failed to fetch projects:", error);
return NextResponse.json(
{ error: "Failed to fetch projects" },
{ status: 500 },

View File

@@ -1,10 +1,8 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs"; // Force Node runtime
const GHOST_API_URL = process.env.GHOST_API_URL;
const GHOST_API_KEY = process.env.GHOST_API_KEY;
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug");
@@ -14,59 +12,37 @@ export async function GET(request: Request) {
}
try {
// Debug: show whether fetch is present/mocked
const project = await prisma.project.findUnique({
where: { slug },
select: {
id: true,
slug: true,
title: true,
updatedAt: true,
metaDescription: true,
description: true,
content: true,
},
});
/* eslint-disable @typescript-eslint/no-explicit-any */
console.log(
"DEBUG fetch in fetchProject:",
typeof (globalThis as any).fetch,
"globalIsMock:",
!!(globalThis as any).fetch?._isMockFunction,
);
// Try global fetch first (as tests often mock it). If it fails or returns undefined,
// fall back to dynamically importing node-fetch.
let response: any;
if (typeof (globalThis as any).fetch === "function") {
try {
response = await (globalThis as any).fetch(
`${GHOST_API_URL}/ghost/api/content/posts/slug/${slug}/?key=${GHOST_API_KEY}`,
);
} catch (_e) {
response = undefined;
}
if (!project) {
return NextResponse.json({ posts: [] }, { status: 200 });
}
if (!response || typeof response.ok === "undefined") {
try {
const mod = await import("node-fetch");
const nodeFetch = (mod as any).default ?? mod;
response = await (nodeFetch as any)(
`${GHOST_API_URL}/ghost/api/content/posts/slug/${slug}/?key=${GHOST_API_KEY}`,
);
} catch (_err) {
response = undefined;
}
}
/* eslint-enable @typescript-eslint/no-explicit-any */
// Debug: inspect the response returned from the fetch
// Debug: inspect the response returned from the fetch
console.log("DEBUG fetch response:", response);
if (!response || !response.ok) {
throw new Error(
`Failed to fetch post: ${response?.statusText ?? "no response"}`,
);
}
const post = await response.json();
return NextResponse.json(post);
// Legacy shape (Ghost-like) for compatibility with older frontend/tests.
return NextResponse.json({
posts: [
{
id: String(project.id),
title: project.title,
meta_description: project.metaDescription ?? project.description ?? "",
slug: project.slug,
updated_at: (project.updatedAt ?? new Date()).toISOString(),
},
],
});
} catch (error) {
console.error("Failed to fetch post from Ghost:", error);
console.error("Failed to fetch project:", error);
return NextResponse.json(
{ error: "Failed to fetch project" },
{ status: 500 },

View File

@@ -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 },
);
}
}

View File

@@ -1,164 +1,22 @@
import { NextResponse } from "next/server";
interface Project {
slug: string;
updated_at?: string; // Optional timestamp for last modification
}
interface ProjectsData {
posts: Project[];
}
import { generateSitemapXml, getSitemapEntries } from "@/lib/sitemap";
export const dynamic = "force-dynamic";
export const runtime = "nodejs"; // Force Node runtime
// Read Ghost API config at runtime, tests may set env vars in beforeAll
// Funktion, um die XML für die Sitemap zu generieren
function generateXml(sitemapRoutes: { url: string; lastModified: string }[]) {
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
const urlsetOpen =
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">';
const urlsetClose = "</urlset>";
const urlEntries = sitemapRoutes
.map(
(route) => `
<url>
<loc>${route.url}</loc>
<lastmod>${route.lastModified}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>`,
)
.join("");
return `${xmlHeader}${urlsetOpen}${urlEntries}${urlsetClose}`;
}
export const runtime = "nodejs";
export async function GET() {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
// Statische Routen
const staticRoutes = [
{
url: `${baseUrl}/`,
lastModified: new Date().toISOString(),
priority: 1,
changeFreq: "weekly",
},
{
url: `${baseUrl}/legal-notice`,
lastModified: new Date().toISOString(),
priority: 0.5,
changeFreq: "yearly",
},
{
url: `${baseUrl}/privacy-policy`,
lastModified: new Date().toISOString(),
priority: 0.5,
changeFreq: "yearly",
},
];
// In test environment we can short-circuit and use a mocked posts payload
if (process.env.NODE_ENV === "test" && process.env.GHOST_MOCK_POSTS) {
const mockData = JSON.parse(process.env.GHOST_MOCK_POSTS);
const projects = (mockData as ProjectsData).posts || [];
const sitemapRoutes = projects.map((project) => {
const lastModified = project.updated_at || new Date().toISOString();
return {
url: `${baseUrl}/projects/${project.slug}`,
lastModified,
priority: 0.8,
changeFreq: "monthly",
};
});
const allRoutes = [...staticRoutes, ...sitemapRoutes];
const xml = generateXml(allRoutes);
// For tests return a plain object so tests can inspect `.body` easily
if (process.env.NODE_ENV === "test") {
return new NextResponse(xml, {
headers: { "Content-Type": "application/xml" },
});
}
try {
const entries = await getSitemapEntries();
const xml = generateSitemapXml(entries);
return new NextResponse(xml, {
headers: { "Content-Type": "application/xml" },
});
}
try {
// Debug: show whether fetch is present/mocked
// Try global fetch first (tests may mock global.fetch)
let response: Response | undefined;
try {
if (typeof globalThis.fetch === "function") {
response = await globalThis.fetch(
`${process.env.GHOST_API_URL}/ghost/api/content/posts/?key=${process.env.GHOST_API_KEY}&limit=all`,
);
// Debug: inspect the result
console.log("DEBUG sitemap global fetch returned:", response);
}
} catch (_e) {
response = undefined;
}
if (!response || typeof response.ok === "undefined" || !response.ok) {
try {
const mod = await import("node-fetch");
const nodeFetch = mod.default ?? mod;
response = await (nodeFetch as unknown as typeof fetch)(
`${process.env.GHOST_API_URL}/ghost/api/content/posts/?key=${process.env.GHOST_API_KEY}&limit=all`,
);
} catch (err) {
console.log("Failed to fetch posts from Ghost:", err);
return new NextResponse(generateXml(staticRoutes), {
headers: { "Content-Type": "application/xml" },
});
}
}
if (!response || !response.ok) {
console.error(
`Failed to fetch posts: ${response?.statusText ?? "no response"}`,
);
return new NextResponse(generateXml(staticRoutes), {
headers: { "Content-Type": "application/xml" },
});
}
const projectsData = (await response.json()) as ProjectsData;
const projects = projectsData.posts;
// Dynamische Projekt-Routen generieren
const sitemapRoutes = projects.map((project) => {
const lastModified = project.updated_at || new Date().toISOString();
return {
url: `${baseUrl}/projects/${project.slug}`,
lastModified,
priority: 0.8,
changeFreq: "monthly",
};
});
const allRoutes = [...staticRoutes, ...sitemapRoutes];
// Rückgabe der Sitemap im XML-Format
return new NextResponse(generateXml(allRoutes), {
headers: { "Content-Type": "application/xml" },
});
} catch (error) {
console.log("Failed to fetch posts from Ghost:", error);
// Rückgabe der statischen Routen, falls Fehler auftritt
return new NextResponse(generateXml(staticRoutes), {
console.error("Failed to generate sitemap:", error);
// Fail closed: return minimal sitemap
const xml = generateSitemapXml([]);
return new NextResponse(xml, {
status: 500,
headers: { "Content-Type": "application/xml" },
});
}

View File

@@ -7,9 +7,6 @@ export default function ConsentBanner() {
const { consent, setConsent } = useConsent();
const [draft, setDraft] = useState<ConsentState>({ analytics: false, chat: false });
const shouldShow = useMemo(() => consent === null, [consent]);
if (!shouldShow) return null;
const locale = useMemo(() => {
if (typeof document === "undefined") return "en";
const match = document.cookie
@@ -20,6 +17,9 @@ export default function ConsentBanner() {
return decodeURIComponent(match.split("=").slice(1).join("=")) || "en";
}, []);
const shouldShow = consent === null;
if (!shouldShow) return null;
const s = locale === "de"
? {
title: "Datenschutz-Einstellungen",

View File

@@ -26,21 +26,21 @@ const KernelPanic404 = dynamic(() => import("./components/KernelPanic404Wrapper"
});
export default function NotFound() {
// In tests, avoid next/dynamic loadable timing and render a stable fallback
if (process.env.NODE_ENV === "test") {
return (
<div>
Oops! The page you're looking for doesn't exist.
</div>
);
}
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// In tests, avoid next/dynamic loadable timing and render a stable fallback
if (process.env.NODE_ENV === "test") {
return (
<div>
Oops! The page you&apos;re looking for doesn&apos;t exist.
</div>
);
}
if (!mounted) {
return (
<div style={{

25
app/robots.txt/route.ts Normal file
View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { getBaseUrl } from "@/lib/seo";
export const dynamic = "force-dynamic";
export async function GET() {
const base = getBaseUrl();
const body = [
"User-agent: *",
"Allow: /",
"Disallow: /api/",
"Disallow: /manage",
"Disallow: /editor",
`Sitemap: ${base}/sitemap.xml`,
"",
].join("\n");
return new NextResponse(body, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
}

View File

@@ -1,67 +1,20 @@
import { NextResponse } from "next/server";
import { generateSitemapXml, getSitemapEntries } from "@/lib/sitemap";
export const dynamic = "force-dynamic";
export async function GET() {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
const apiUrl = `${baseUrl}/api/sitemap`; // Verwende die vollständige URL zur API
// In test runs, allow returning a mocked sitemap explicitly
if (process.env.NODE_ENV === "test" && process.env.GHOST_MOCK_SITEMAP) {
// For tests return a simple object so tests can inspect `.body`
if (process.env.NODE_ENV === "test") {
/* eslint-disable @typescript-eslint/no-explicit-any */
return {
body: process.env.GHOST_MOCK_SITEMAP,
headers: { "Content-Type": "application/xml" },
} as any;
/* eslint-enable @typescript-eslint/no-explicit-any */
}
return new NextResponse(process.env.GHOST_MOCK_SITEMAP, {
headers: { "Content-Type": "application/xml" },
});
}
try {
// Holt die Sitemap-Daten von der API
// Try global fetch first, then fall back to node-fetch
/* eslint-disable @typescript-eslint/no-explicit-any */
let res: any;
try {
if (typeof (globalThis as any).fetch === "function") {
res = await (globalThis as any).fetch(apiUrl);
}
} catch (_e) {
res = undefined;
}
if (!res || typeof res.ok === "undefined" || !res.ok) {
try {
const mod = await import("node-fetch");
const nodeFetch = (mod as any).default ?? mod;
res = await (nodeFetch as any)(apiUrl);
} catch (err) {
console.error("Error fetching sitemap:", err);
return new NextResponse("Error fetching sitemap", { status: 500 });
}
}
/* eslint-enable @typescript-eslint/no-explicit-any */
if (!res || !res.ok) {
console.error(
`Failed to fetch sitemap: ${res?.statusText ?? "no response"}`,
);
return new NextResponse("Failed to fetch sitemap", { status: 500 });
}
const xml = await res.text();
// Gibt die XML mit dem richtigen Content-Type zurück
const entries = await getSitemapEntries();
const xml = generateSitemapXml(entries);
return new NextResponse(xml, {
headers: { "Content-Type": "application/xml" },
});
} catch (error) {
console.error("Error fetching sitemap:", error);
return new NextResponse("Error fetching sitemap", { status: 500 });
console.error("Error generating sitemap.xml:", error);
return new NextResponse(generateSitemapXml([]), {
status: 500,
headers: { "Content-Type": "application/xml" },
});
}
}

View File

@@ -18,6 +18,9 @@ services:
- MY_PASSWORD=${MY_PASSWORD}
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:your_secure_password_here}
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
# If you already have an existing DB (pre-migrations), set this to true ONCE to baseline.
- PRISMA_AUTO_BASELINE=${PRISMA_AUTO_BASELINE:-false}
- LOG_LEVEL=info
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}

View File

@@ -18,6 +18,8 @@ services:
- MY_PASSWORD=${MY_PASSWORD}
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:testing_password}
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
- PRISMA_AUTO_BASELINE=${PRISMA_AUTO_BASELINE:-false}
- LOG_LEVEL=info
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}

View File

@@ -41,6 +41,7 @@ Dann SSL Zertifikate (Lets Encrypt) aktivieren.
- `MY_PASSWORD`
- `MY_INFO_PASSWORD`
- `ADMIN_BASIC_AUTH` (z.B. `admin:<starkes_passwort>`)
- `ADMIN_SESSION_SECRET` (mind. 32 Zeichen, zufällig; für Session-Login im Admin)
- optional: `N8N_SECRET_TOKEN`
## Docker Compose Files
@@ -50,3 +51,19 @@ Dann SSL Zertifikate (Lets Encrypt) aktivieren.
Wenn du “dev” nicht mehr brauchst, kannst du den Branch einfach nicht mehr benutzen.
## Prisma Migrations (Auto-Deploy)
Der App-Container führt beim Start automatisch aus:
- `prisma migrate deploy`
### Wichtig: bestehende Datenbank (Baseline)
Wenn deine DB bereits existiert (vor Einführung von Prisma Migrations), dann würde die initiale Migration sonst mit “table already exists” scheitern.
**Einmalig beim ersten Deploy**:
- Setze `PRISMA_AUTO_BASELINE=true` (z.B. als Compose env oder Gitea Variable/Secret)
- Deploy ausführen
- Danach wieder auf `false` setzen
Alternative (manuell/sauber):
- Baseline per `prisma migrate resolve --applied <init_migration_name>` ausführen (z.B. lokal gegen die Prod-DB)

27
e2e/consent.spec.ts Normal file
View File

@@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
test.describe("Consent banner", () => {
test("banner shows and can be accepted", async ({ page, context }) => {
// Start clean
await context.clearCookies();
await page.goto("/en", { waitUntil: "domcontentloaded" });
// Banner should appear on public pages when no consent is set yet
const bannerTitle = page.getByText(/Privacy settings|Datenschutz-Einstellungen/i);
await expect(bannerTitle).toBeVisible({ timeout: 10000 });
// Accept all
const acceptAll = page.getByRole("button", { name: /Accept all|Alles akzeptieren/i });
await acceptAll.click();
// Banner disappears
await expect(bannerTitle).toBeHidden({ timeout: 10000 });
// Cookie is written
const cookies = await context.cookies();
const consentCookie = cookies.find((c) => c.name === "dk0_consent_v1");
expect(consentCookie).toBeTruthy();
});
});

View File

@@ -6,7 +6,7 @@ import { test, expect } from '@playwright/test';
*/
test.describe('Critical Paths', () => {
test('Home page loads and displays correctly', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
await page.goto('/en', { waitUntil: 'networkidle' });
// Wait for page to be fully loaded
await page.waitForLoadState('domcontentloaded');
@@ -25,7 +25,7 @@ test.describe('Critical Paths', () => {
});
test('Projects page loads and displays projects', async ({ page }) => {
await page.goto('/projects', { waitUntil: 'networkidle' });
await page.goto('/en/projects', { waitUntil: 'networkidle' });
// Wait for projects to load
await page.waitForLoadState('domcontentloaded');
@@ -45,7 +45,7 @@ test.describe('Critical Paths', () => {
test('Individual project page loads', async ({ page }) => {
// First, get a project slug from the projects page
await page.goto('/projects', { waitUntil: 'networkidle' });
await page.goto('/en/projects', { waitUntil: 'networkidle' });
await page.waitForLoadState('domcontentloaded');
// Try to find a project link

View File

@@ -20,7 +20,7 @@ test.describe('Hydration Tests', () => {
});
// Navigate to home page
await page.goto('/', { waitUntil: 'networkidle' });
await page.goto('/en', { waitUntil: 'networkidle' });
await page.waitForLoadState('domcontentloaded');
// Check for hydration errors
@@ -51,7 +51,7 @@ test.describe('Hydration Tests', () => {
}
});
await page.goto('/');
await page.goto('/en');
await page.waitForLoadState('networkidle');
// Check for duplicate key warnings
@@ -71,11 +71,11 @@ test.describe('Hydration Tests', () => {
}
});
await page.goto('/', { waitUntil: 'networkidle' });
await page.goto('/en', { waitUntil: 'networkidle' });
await page.waitForLoadState('domcontentloaded');
// Navigate to projects page via link
const projectsLink = page.locator('a[href="/projects"], a[href*="projects"]').first();
const projectsLink = page.locator('a[href*="/projects"]').first();
if (await projectsLink.count() > 0) {
await projectsLink.click();
await page.waitForLoadState('domcontentloaded');
@@ -90,7 +90,7 @@ test.describe('Hydration Tests', () => {
});
test('Server and client HTML match', async ({ page }) => {
await page.goto('/');
await page.goto('/en');
// Get initial HTML
const initialHTML = await page.content();
@@ -108,7 +108,7 @@ test.describe('Hydration Tests', () => {
});
test('Interactive elements work after hydration', async ({ page }) => {
await page.goto('/');
await page.goto('/en');
await page.waitForLoadState('networkidle');
// Try to find and click interactive elements

17
e2e/i18n.spec.ts Normal file
View File

@@ -0,0 +1,17 @@
import { test, expect } from "@playwright/test";
test.describe("i18n routing", () => {
test("language switcher navigates between locales", async ({ page }) => {
await page.goto("/en", { waitUntil: "domcontentloaded" });
// Buttons are "EN"/"DE" in the header
const deButton = page.getByRole("button", { name: "DE" });
if (await deButton.count()) {
await deButton.click();
await expect(page).toHaveURL(/\/de(\/|$)/);
} else {
test.skip();
}
});
});

22
e2e/seo.spec.ts Normal file
View File

@@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
test.describe("SEO endpoints", () => {
test("robots.txt is served and contains sitemap", async ({ request }) => {
const res = await request.get("/robots.txt");
expect(res.ok()).toBeTruthy();
const txt = await res.text();
expect(txt).toContain("User-agent:");
expect(txt).toContain("Sitemap:");
});
test("sitemap.xml is served and contains locale routes", async ({ request }) => {
const res = await request.get("/sitemap.xml");
expect(res.ok()).toBeTruthy();
const xml = await res.text();
expect(xml).toContain('<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">');
// At least the localized home routes should exist
expect(xml).toMatch(/\/en<\/loc>/);
expect(xml).toMatch(/\/de<\/loc>/);
});
});

View File

@@ -34,6 +34,14 @@ N8N_API_KEY=your-n8n-api-key
# JWT_SECRET=your-jwt-secret
# ENCRYPTION_KEY=your-encryption-key
ADMIN_BASIC_AUTH=admin:your_secure_password_here
ADMIN_SESSION_SECRET=change_me_to_a_long_random_string_at_least_32_chars
# Prisma migrations at container startup
# - default: migrations are executed (`prisma migrate deploy`)
# - set to true ONCE if you already have an existing DB that was created before migrations existed
PRISMA_AUTO_BASELINE=false
# emergency switch (not recommended for normal operation)
# SKIP_PRISMA_MIGRATE=true
# Monitoring (optional)
# SENTRY_DSN=your-sentry-dsn

3
i18n/locales.ts Normal file
View File

@@ -0,0 +1,3 @@
export const locales = ["en", "de"] as const;
export type AppLocale = (typeof locales)[number];

View File

@@ -1,7 +1,6 @@
import { getRequestConfig } from 'next-intl/server';
export const locales = ['en', 'de'] as const;
export type AppLocale = (typeof locales)[number];
import { getRequestConfig } from "next-intl/server";
import { locales } from "./locales";
export { locales, type AppLocale } from "./locales";
export default getRequestConfig(async ({ locale }) => {
// next-intl can call us with unknown/undefined locales; fall back safely

View File

@@ -1,4 +1,5 @@
import { prisma } from "@/lib/prisma";
import type { Prisma } from "@prisma/client";
export async function getSiteSettings() {
return prisma.siteSettings.findUnique({ where: { id: 1 } });
@@ -55,14 +56,14 @@ export async function upsertContentByKey(opts: {
locale,
title: title ?? undefined,
slug: slug ?? undefined,
content: content as any, // JSON
content: content as Prisma.InputJsonValue, // JSON
metaDescription: metaDescription ?? undefined,
keywords: keywords ?? undefined,
},
update: {
title: title ?? undefined,
slug: slug ?? undefined,
content: content as any, // JSON
content: content as Prisma.InputJsonValue, // JSON
metaDescription: metaDescription ?? undefined,
keywords: keywords ?? undefined,
},

30
lib/seo.ts Normal file
View File

@@ -0,0 +1,30 @@
import { locales, type AppLocale } from "@/i18n/locales";
export function getBaseUrl(): string {
const raw =
process.env.NEXT_PUBLIC_BASE_URL ||
process.env.NEXTAUTH_URL || // fallback if ever added
"http://localhost:3000";
return raw.replace(/\/+$/, "");
}
export function toAbsoluteUrl(path: string): string {
const base = getBaseUrl();
const normalized = path.startsWith("/") ? path : `/${path}`;
return `${base}${normalized}`;
}
export function getLanguageAlternates(opts: {
/** Path without locale prefix, e.g. "/projects" or "/projects/my-slug" or "" */
pathWithoutLocale: string;
}): Record<AppLocale, string> {
const path = opts.pathWithoutLocale === "" ? "" : `/${opts.pathWithoutLocale}`.replace(/\/{2,}/g, "/");
const normalizedPath = path === "/" ? "" : path;
return locales.reduce((acc, l) => {
const url = toAbsoluteUrl(`/${l}${normalizedPath}`);
acc[l] = url;
return acc;
}, {} as Record<AppLocale, string>);
}

70
lib/sitemap.ts Normal file
View File

@@ -0,0 +1,70 @@
import { prisma } from "@/lib/prisma";
import { locales } from "@/i18n/locales";
import { getBaseUrl } from "@/lib/seo";
export type SitemapEntry = {
url: string;
lastModified: string;
changefreq?: "daily" | "weekly" | "monthly" | "yearly";
priority?: number;
};
export function generateSitemapXml(entries: SitemapEntry[]): string {
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
const urlsetOpen = '<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">';
const urlsetClose = "</urlset>";
const urlEntries = entries
.map((e) => {
const changefreq = e.changefreq ?? "monthly";
const priority = typeof e.priority === "number" ? e.priority : 0.8;
return `
<url>
<loc>${e.url}</loc>
<lastmod>${e.lastModified}</lastmod>
<changefreq>${changefreq}</changefreq>
<priority>${priority.toFixed(1)}</priority>
</url>`;
})
.join("");
return `${xmlHeader}${urlsetOpen}${urlEntries}${urlsetClose}`;
}
export async function getSitemapEntries(): Promise<SitemapEntry[]> {
const baseUrl = getBaseUrl();
const nowIso = new Date().toISOString();
const staticPaths = ["", "/projects", "/legal-notice", "/privacy-policy"];
const staticEntries: SitemapEntry[] = locales.flatMap((locale) =>
staticPaths.map((p) => {
const path = p === "" ? `/${locale}` : `/${locale}${p}`;
return {
url: `${baseUrl}${path}`,
lastModified: nowIso,
changefreq: p === "" ? "weekly" : p === "/projects" ? "weekly" : "yearly",
priority: p === "" ? 1.0 : p === "/projects" ? 0.8 : 0.5,
};
}),
);
// Projects: for each project slug we publish per locale (same slug)
const projects = await prisma.project.findMany({
where: { published: true },
select: { slug: true, updatedAt: true },
orderBy: { updatedAt: "desc" },
});
const projectEntries: SitemapEntry[] = projects.flatMap((p) => {
const lastModified = (p.updatedAt ?? new Date()).toISOString();
return locales.map((locale) => ({
url: `${baseUrl}/${locale}/projects/${p.slug}`,
lastModified,
changefreq: "monthly",
priority: 0.7,
}));
});
return [...staticEntries, ...projectEntries];
}

View File

@@ -1,5 +0,0 @@
User-agent: *
Allow: /
Disallow: /legal-notice
Disallow: /privacy-policy
Sitemap: https://dki.one/sitemap.xml

View File

@@ -80,7 +80,7 @@ echo -e "${YELLOW}[4/5] Verifying critical files...${NC}"
REQUIRED_FILES=(
"CHANGELOG_DEV.md"
"AFTER_PUSH_SETUP.md"
"prisma/migrations/create_activity_status.sql"
"prisma/migrations/migration_lock.toml"
"docs/ai-image-generation/README.md"
)
MISSING=0

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/**
* Container entrypoint: apply Prisma migrations, then start Next server.
*
@@ -9,6 +10,8 @@
* - Set `SKIP_PRISMA_MIGRATE=true` to skip migrations (emergency / debugging).
*/
const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, {
@@ -28,11 +31,36 @@ function run(cmd, args, opts = {}) {
const skip = String(process.env.SKIP_PRISMA_MIGRATE || "").toLowerCase() === "true";
if (!skip) {
const autoBaseline =
String(process.env.PRISMA_AUTO_BASELINE || "").toLowerCase() === "true";
// Avoid relying on `npx` resolution in minimal runtimes.
// We copy `node_modules/prisma` into the runtime image.
if (autoBaseline) {
try {
const migrationsDir = path.join(process.cwd(), "prisma", "migrations");
const entries = fs
.readdirSync(migrationsDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
const initMigration = entries.find((n) => n.endsWith("_init"));
if (initMigration) {
// This is the documented "baseline" flow for existing databases:
// mark the initial migration as already applied.
run("node", [
"node_modules/prisma/build/index.js",
"migrate",
"resolve",
"--applied",
initMigration,
]);
}
} catch (_err) {
// If baseline fails we continue to migrate deploy, which will surface the real issue.
}
}
run("node", ["node_modules/prisma/build/index.js", "migrate", "deploy"]);
} else {
// eslint-disable-next-line no-console
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy");
}

View File

@@ -1,4 +0,0 @@
{
"status": "interrupted",
"failedTests": []
}