From 423a2af938070d12f7afa206c4ab0349d722bda2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Jan 2026 15:27:35 +0000 Subject: [PATCH] Integrate Prisma for content; enhance SEO, i18n, and deployment workflows Co-authored-by: dennis --- .gitea/workflows/dev-deploy.yml | 1 + .gitea/workflows/production-deploy.yml | 2 + GITEA_VARIABLES_SETUP.md | 3 + app/[locale]/legal-notice/page.tsx | 17 ++ app/[locale]/page.tsx | 23 +- app/[locale]/privacy-policy/page.tsx | 17 ++ app/[locale]/projects/[slug]/page.tsx | 17 ++ app/[locale]/projects/page.tsx | 17 ++ app/__tests__/api/fetchAllProjects.test.tsx | 70 ++--- app/__tests__/api/fetchProject.test.tsx | 49 ++- app/__tests__/api/sitemap.test.tsx | 95 ++---- app/__tests__/sitemap.xml/page.test.tsx | 64 +--- app/api/fetchAllProjects/route.tsx | 68 ++--- app/api/fetchProject/route.tsx | 80 ++--- app/api/projects/import/route.ts | 319 ++++++++++++++------ app/api/sitemap/route.tsx | 162 +--------- app/components/ConsentBanner.tsx | 6 +- app/not-found.tsx | 18 +- app/robots.txt/route.ts | 25 ++ app/sitemap.xml/route.tsx | 63 +--- docker-compose.production.yml | 3 + docker-compose.testing.yml | 2 + docs/TESTING_AND_DEPLOYMENT.md | 17 ++ e2e/consent.spec.ts | 27 ++ e2e/critical-paths.spec.ts | 6 +- e2e/hydration.spec.ts | 12 +- e2e/i18n.spec.ts | 17 ++ e2e/seo.spec.ts | 22 ++ env.example | 8 + i18n/locales.ts | 3 + i18n/request.ts | 7 +- lib/content.ts | 5 +- lib/seo.ts | 30 ++ lib/sitemap.ts | 70 +++++ public/robots.txt | 5 - push-to-dev.sh | 2 +- scripts/start-with-migrate.js | 30 +- test-results/.last-run.json | 4 - 38 files changed, 757 insertions(+), 629 deletions(-) create mode 100644 app/robots.txt/route.ts create mode 100644 e2e/consent.spec.ts create mode 100644 e2e/i18n.spec.ts create mode 100644 e2e/seo.spec.ts create mode 100644 i18n/locales.ts create mode 100644 lib/seo.ts create mode 100644 lib/sitemap.ts delete mode 100644 public/robots.txt delete mode 100644 test-results/.last-run.json diff --git a/.gitea/workflows/dev-deploy.yml b/.gitea/workflows/dev-deploy.yml index 25e7efc..640885c 100644 --- a/.gitea/workflows/dev-deploy.yml +++ b/.gitea/workflows/dev-deploy.yml @@ -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 || '' }} diff --git a/.gitea/workflows/production-deploy.yml b/.gitea/workflows/production-deploy.yml index f3b4d40..a2eb0ba 100644 --- a/.gitea/workflows/production-deploy.yml +++ b/.gitea/workflows/production-deploy.yml @@ -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 || '' }} diff --git a/GITEA_VARIABLES_SETUP.md b/GITEA_VARIABLES_SETUP.md index 83104b4..1c12127 100644 --- a/GITEA_VARIABLES_SETUP.md +++ b/GITEA_VARIABLES_SETUP.md @@ -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:** diff --git a/app/[locale]/legal-notice/page.tsx b/app/[locale]/legal-notice/page.tsx index c4963d3..4ab1de6 100644 --- a/app/[locale]/legal-notice/page.tsx +++ b/app/[locale]/legal-notice/page.tsx @@ -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 { + const { locale } = await params; + const languages = getLanguageAlternates({ pathWithoutLocale: "legal-notice" }); + return { + alternates: { + canonical: toAbsoluteUrl(`/${locale}/legal-notice`), + languages, + }, + }; +} + diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index f93682d..4068978 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -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 { + const { locale } = await params; + const languages = getLanguageAlternates({ pathWithoutLocale: "" }); + return { + alternates: { + canonical: toAbsoluteUrl(`/${locale}`), + languages, + }, + }; +} + +export default function Page() { + return ; +} diff --git a/app/[locale]/privacy-policy/page.tsx b/app/[locale]/privacy-policy/page.tsx index 67cb9e3..1f5b0cd 100644 --- a/app/[locale]/privacy-policy/page.tsx +++ b/app/[locale]/privacy-policy/page.tsx @@ -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 { + const { locale } = await params; + const languages = getLanguageAlternates({ pathWithoutLocale: "privacy-policy" }); + return { + alternates: { + canonical: toAbsoluteUrl(`/${locale}/privacy-policy`), + languages, + }, + }; +} + diff --git a/app/[locale]/projects/[slug]/page.tsx b/app/[locale]/projects/[slug]/page.tsx index 06de5e9..b5571e5 100644 --- a/app/[locale]/projects/[slug]/page.tsx +++ b/app/[locale]/projects/[slug]/page.tsx @@ -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 { + 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, }: { diff --git a/app/[locale]/projects/page.tsx b/app/[locale]/projects/page.tsx index 5e4a9cd..0dffa8d 100644 --- a/app/[locale]/projects/page.tsx +++ b/app/[locale]/projects/page.tsx @@ -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 { + const { locale } = await params; + const languages = getLanguageAlternates({ pathWithoutLocale: "projects" }); + return { + alternates: { + canonical: toAbsoluteUrl(`/${locale}/projects`), + languages, + }, + }; +} + export default async function ProjectsPage({ params, }: { diff --git a/app/__tests__/api/fetchAllProjects.test.tsx b/app/__tests__/api/fetchAllProjects.test.tsx index 1ffba9f..3375c14 100644 --- a/app/__tests__/api/fetchAllProjects.test.tsx +++ b/app/__tests__/api/fetchAllProjects.test.tsx @@ -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', }), ]), diff --git a/app/__tests__/api/fetchProject.test.tsx b/app/__tests__/api/fetchProject.test.tsx index 85e443c..c53a5c9 100644 --- a/app/__tests__/api/fetchProject.test.tsx +++ b/app/__tests__/api/fetchProject.test.tsx @@ -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', }, ], }); diff --git a/app/__tests__/api/sitemap.test.tsx b/app/__tests__/api/sitemap.test.tsx index 0a17e68..91e1e9e 100644 --- a/app/__tests__/api/sitemap.test.tsx +++ b/app/__tests__/api/sitemap.test.tsx @@ -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( + () => + 'https://dki.one/en', ), })); 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( '', ); - expect(body).toContain("https://dki.one/"); - expect(body).toContain("https://dki.one/legal-notice"); - expect(body).toContain("https://dki.one/privacy-policy"); - expect(body).toContain( - "https://dki.one/projects/just-doing-some-testing", - ); - expect(body).toContain( - "https://dki.one/projects/blockchain-based-voting-system", - ); + expect(body).toContain("https://dki.one/en"); // Note: Headers are not available in test environment }); }); diff --git a/app/__tests__/sitemap.xml/page.test.tsx b/app/__tests__/sitemap.xml/page.test.tsx index 7511683..e884fe0 100644 --- a/app/__tests__/sitemap.xml/page.test.tsx +++ b/app/__tests__/sitemap.xml/page.test.tsx @@ -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 = ` - - - https://dki.one/ - - - https://dki.one/legal-notice - - - https://dki.one/privacy-policy - - - https://dki.one/projects/just-doing-some-testing - - - https://dki.one/projects/blockchain-based-voting-system - - -`; - -// 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( + () => + 'https://dki.one/en', ), })); 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( '', ); - expect(response.body).toContain("https://dki.one/"); - expect(response.body).toContain("https://dki.one/legal-notice"); - expect(response.body).toContain( - "https://dki.one/privacy-policy", - ); - expect(response.body).toContain( - "https://dki.one/projects/just-doing-some-testing", - ); - expect(response.body).toContain( - "https://dki.one/projects/blockchain-based-voting-system", - ); + expect(response.body).toContain("https://dki.one/en"); // Note: Headers are not available in test environment }); }); diff --git a/app/api/fetchAllProjects/route.tsx b/app/api/fetchAllProjects/route.tsx index a698325..05f0a7c 100644 --- a/app/api/fetchAllProjects/route.tsx +++ b/app/api/fetchAllProjects/route.tsx @@ -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; +type LegacyPostsResponse = { + posts: Array; }; export async function GET() { - const cacheKey = "ghostPosts"; - const cachedPosts = cache.get(cacheKey); + const cacheKey = "projects:legacyPosts"; + const cachedPosts = cache.get(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 }, diff --git a/app/api/fetchProject/route.tsx b/app/api/fetchProject/route.tsx index b01a4bd..79a1a81 100644 --- a/app/api/fetchProject/route.tsx +++ b/app/api/fetchProject/route.tsx @@ -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 }, diff --git a/app/api/projects/import/route.ts b/app/api/projects/import/route.ts index 33dfef6..1bfed8f 100644 --- a/app/api/projects/import/route.ts +++ b/app/api/projects/import/route.ts @@ -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) } as any, - update: { ...(body.siteSettings as Record) } 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 | null) || { lighthouse: 0, - bundleSize: '0KB', - loadTime: '0s' + bundleSize: "0KB", + loadTime: "0s", }, - analytics: projectData.analytics || { + analytics: (projectData.analytics as Record | 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 }, ); } } diff --git a/app/api/sitemap/route.tsx b/app/api/sitemap/route.tsx index b8c56f3..1c74d26 100644 --- a/app/api/sitemap/route.tsx +++ b/app/api/sitemap/route.tsx @@ -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 = ''; - const urlsetOpen = - ''; - const urlsetClose = ""; - - const urlEntries = sitemapRoutes - .map( - (route) => ` - - ${route.url} - ${route.lastModified} - monthly - 0.8 - `, - ) - .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" }, }); } diff --git a/app/components/ConsentBanner.tsx b/app/components/ConsentBanner.tsx index 94b8423..d23b575 100644 --- a/app/components/ConsentBanner.tsx +++ b/app/components/ConsentBanner.tsx @@ -7,9 +7,6 @@ export default function ConsentBanner() { const { consent, setConsent } = useConsent(); const [draft, setDraft] = useState({ 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", diff --git a/app/not-found.tsx b/app/not-found.tsx index b331c8b..57d1631 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -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 ( -
- Oops! The page you're looking for doesn't exist. -
- ); - } - 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 ( +
+ Oops! The page you're looking for doesn't exist. +
+ ); + } + if (!mounted) { return (
`) +- `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 (Let’s 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 ` ausführen (z.B. lokal gegen die Prod-DB) + diff --git a/e2e/consent.spec.ts b/e2e/consent.spec.ts new file mode 100644 index 0000000..f3adc4f --- /dev/null +++ b/e2e/consent.spec.ts @@ -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(); + }); +}); + diff --git a/e2e/critical-paths.spec.ts b/e2e/critical-paths.spec.ts index 9fdee8c..ead8788 100644 --- a/e2e/critical-paths.spec.ts +++ b/e2e/critical-paths.spec.ts @@ -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 diff --git a/e2e/hydration.spec.ts b/e2e/hydration.spec.ts index 5221054..3522bfb 100644 --- a/e2e/hydration.spec.ts +++ b/e2e/hydration.spec.ts @@ -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 diff --git a/e2e/i18n.spec.ts b/e2e/i18n.spec.ts new file mode 100644 index 0000000..54b9f49 --- /dev/null +++ b/e2e/i18n.spec.ts @@ -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(); + } + }); +}); + diff --git a/e2e/seo.spec.ts b/e2e/seo.spec.ts new file mode 100644 index 0000000..d1b85e3 --- /dev/null +++ b/e2e/seo.spec.ts @@ -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(''); + // At least the localized home routes should exist + expect(xml).toMatch(/\/en<\/loc>/); + expect(xml).toMatch(/\/de<\/loc>/); + }); +}); + diff --git a/env.example b/env.example index cec1add..6a3efd5 100644 --- a/env.example +++ b/env.example @@ -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 diff --git a/i18n/locales.ts b/i18n/locales.ts new file mode 100644 index 0000000..c2abae3 --- /dev/null +++ b/i18n/locales.ts @@ -0,0 +1,3 @@ +export const locales = ["en", "de"] as const; +export type AppLocale = (typeof locales)[number]; + diff --git a/i18n/request.ts b/i18n/request.ts index 7d6a3c7..cf82189 100644 --- a/i18n/request.ts +++ b/i18n/request.ts @@ -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 diff --git a/lib/content.ts b/lib/content.ts index 12ce851..68888ed 100644 --- a/lib/content.ts +++ b/lib/content.ts @@ -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, }, diff --git a/lib/seo.ts b/lib/seo.ts new file mode 100644 index 0000000..d153ec4 --- /dev/null +++ b/lib/seo.ts @@ -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 { + 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); +} + diff --git a/lib/sitemap.ts b/lib/sitemap.ts new file mode 100644 index 0000000..4405283 --- /dev/null +++ b/lib/sitemap.ts @@ -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 = ''; + const urlsetOpen = ''; + const urlsetClose = ""; + + const urlEntries = entries + .map((e) => { + const changefreq = e.changefreq ?? "monthly"; + const priority = typeof e.priority === "number" ? e.priority : 0.8; + return ` + + ${e.url} + ${e.lastModified} + ${changefreq} + ${priority.toFixed(1)} + `; + }) + .join(""); + + return `${xmlHeader}${urlsetOpen}${urlEntries}${urlsetClose}`; +} + +export async function getSitemapEntries(): Promise { + 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]; +} + diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index 69da470..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,5 +0,0 @@ -User-agent: * -Allow: / -Disallow: /legal-notice -Disallow: /privacy-policy -Sitemap: https://dki.one/sitemap.xml diff --git a/push-to-dev.sh b/push-to-dev.sh index ffdb42f..f98dd2b 100755 --- a/push-to-dev.sh +++ b/push-to-dev.sh @@ -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 diff --git a/scripts/start-with-migrate.js b/scripts/start-with-migrate.js index 1a31f69..bc3c2bf 100644 --- a/scripts/start-with-migrate.js +++ b/scripts/start-with-migrate.js @@ -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"); } diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index 344ea9e..0000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "interrupted", - "failedTests": [] -} \ No newline at end of file