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_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }} MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }} ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}

View File

@@ -69,6 +69,7 @@ jobs:
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}" export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" 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) # Start new container with updated image (docker-compose will handle this)
echo "🆕 Starting new production container..." echo "🆕 Starting new production container..."
@@ -202,6 +203,7 @@ jobs:
MY_PASSWORD: ${{ secrets.MY_PASSWORD }} MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }} MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }} ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }} 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_PASSWORD` = Dein Email-Passwort
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort - `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
- `ADMIN_BASIC_AUTH` = `admin:dein_sicheres_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) - `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
## 🧪 Variablen für Dev Branch ## 🧪 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_PASSWORD` = Dein Email-Passwort (kann gleich sein)
- `MY_INFO_PASSWORD` = Dein Info-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_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) - `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
## ✅ Lösung: Automatische Branch-Erkennung ## ✅ Lösung: Automatische Branch-Erkennung
@@ -89,6 +91,7 @@ Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch!
- `MY_PASSWORD` = Dein Email-Passwort - `MY_PASSWORD` = Dein Email-Passwort
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort - `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
- `ADMIN_BASIC_AUTH` = `admin:dein_passwort` - `ADMIN_BASIC_AUTH` = `admin:dein_passwort`
- `ADMIN_SESSION_SECRET` = zufälliger Secret (mind. 32 Zeichen)
- `N8N_SECRET_TOKEN` = Dein n8n Token (optional) - `N8N_SECRET_TOKEN` = Dein n8n Token (optional)
**Optional setzen:** **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 { 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 { 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 { prisma } from "@/lib/prisma";
import ProjectDetailClient from "@/app/_ui/ProjectDetailClient"; import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export const revalidate = 300; 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({ export default async function ProjectPage({
params, params,
}: { }: {

View File

@@ -1,8 +1,25 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import ProjectsPageClient from "@/app/_ui/ProjectsPageClient"; import ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export const revalidate = 300; 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({ export default async function ProjectsPage({
params, params,
}: { }: {

View File

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

View File

@@ -1,26 +1,23 @@
import { GET } from '@/app/api/fetchProject/route';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
// Mock node-fetch so the route uses it as a reliable fallback jest.mock('@/lib/prisma', () => ({
jest.mock('node-fetch', () => ({ prisma: {
__esModule: true, project: {
default: jest.fn(() => findUnique: jest.fn(async ({ where }: { where: { slug: string } }) => {
Promise.resolve({ if (where.slug !== 'blockchain-based-voting-system') return null;
ok: true, return {
json: () => id: 2,
Promise.resolve({
posts: [
{
id: '67aaffc3709c60000117d2d9',
title: 'Blockchain Based Voting System', title: 'Blockchain Based Voting System',
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.', metaDescription:
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
slug: 'blockchain-based-voting-system', slug: 'blockchain-based-voting-system',
updated_at: '2025-02-13T16:54:42.000+00:00', updatedAt: new Date('2025-02-13T16:54:42.000Z'),
}, description: null,
], content: null,
};
}), }),
}) },
), },
})); }));
jest.mock('next/server', () => ({ jest.mock('next/server', () => ({
@@ -29,12 +26,8 @@ jest.mock('next/server', () => ({
}, },
})); }));
describe('GET /api/fetchProject', () => { 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 () => { it('should fetch a project by slug', async () => {
const { GET } = await import('@/app/api/fetchProject/route');
const mockRequest = { const mockRequest = {
url: 'http://localhost/api/fetchProject?slug=blockchain-based-voting-system', url: 'http://localhost/api/fetchProject?slug=blockchain-based-voting-system',
} as unknown as NextRequest; } as unknown as NextRequest;
@@ -44,11 +37,11 @@ describe('GET /api/fetchProject', () => {
expect(NextResponse.json).toHaveBeenCalledWith({ expect(NextResponse.json).toHaveBeenCalledWith({
posts: [ posts: [
{ {
id: '67aaffc3709c60000117d2d9', id: '2',
title: 'Blockchain Based Voting System', title: 'Blockchain Based Voting System',
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.', meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
slug: 'blockchain-based-voting-system', 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"; jest.mock("@/lib/sitemap", () => ({
getSitemapEntries: jest.fn(async () => [
// 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", url: "https://dki.one/en",
title: "Just Doing Some Testing", lastModified: "2025-01-01T00:00:00.000Z",
meta_description: "Hello bla bla bla bla",
slug: "just-doing-some-testing",
updated_at: "2025-02-13T14:25:38.000+00:00",
}, },
{ {
id: "67aaffc3709c60000117d2d9", url: "https://dki.one/de",
title: "Blockchain Based Voting System", lastModified: "2025-01-01T00:00:00.000Z",
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: { url: "https://dki.one/en/projects/blockchain-based-voting-system",
pagination: { lastModified: "2025-02-13T16:54:42.000Z",
limit: "all",
next: null,
page: 1,
pages: 1,
prev: null,
total: 2,
}, },
{
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", () => { describe("GET /api/sitemap", () => {
beforeAll(() => { 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"; 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 () => { it("should return a sitemap", async () => {
const { GET } = await import("@/app/api/sitemap/route");
const response = await GET(); const response = await GET();
// Get the body text from the NextResponse // Get the body text from the NextResponse
@@ -113,15 +74,7 @@ describe("GET /api/sitemap", () => {
expect(body).toContain( expect(body).toContain(
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">', '<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/en</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>",
);
// Note: Headers are not available in test environment // Note: Headers are not available in test environment
}); });
}); });

View File

@@ -1,5 +1,4 @@
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import { GET } from "@/app/sitemap.xml/route";
jest.mock("next/server", () => ({ jest.mock("next/server", () => ({
NextResponse: jest.fn().mockImplementation((body: unknown, init?: ResponseInit) => { NextResponse: jest.fn().mockImplementation((body: unknown, init?: ResponseInit) => {
@@ -11,71 +10,32 @@ jest.mock("next/server", () => ({
}), }),
})); }));
// Sitemap XML used by node-fetch mock jest.mock("@/lib/sitemap", () => ({
const sitemapXml = ` getSitemapEntries: jest.fn(async () => [
<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"> {
<url> url: "https://dki.one/en",
<loc>https://dki.one/</loc> lastModified: "2025-01-01T00:00:00.000Z",
</url> },
<url> ]),
<loc>https://dki.one/legal-notice</loc> generateSitemapXml: jest.fn(
</url> () =>
<url> '<?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>',
<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) }),
), ),
})); }));
describe("Sitemap Component", () => { describe("Sitemap Component", () => {
beforeAll(() => { beforeAll(() => {
process.env.NEXT_PUBLIC_BASE_URL = "https://dki.one"; 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 () => { it("should render the sitemap XML", async () => {
const { GET } = await import("@/app/sitemap.xml/route");
const response = await GET(); const response = await GET();
expect(response.body).toContain( expect(response.body).toContain(
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">', '<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/en</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>",
);
// Note: Headers are not available in test environment // Note: Headers are not available in test environment
}); });
}); });

View File

@@ -1,66 +1,58 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import { prisma } from "@/lib/prisma";
// 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;
}
}
export const runtime = "nodejs"; // Force Node runtime 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 const cache = new NodeCache({ stdTTL: 300 }); // Cache für 5 Minuten
type GhostPost = { type LegacyPost = {
slug: string; slug: string;
id: string; id: string;
title: string; title: string;
feature_image: string; meta_description: string | null;
visibility: string;
published_at: string;
updated_at: string; updated_at: string;
html: string;
reading_time: number;
meta_description: string;
}; };
type GhostPostsResponse = { type LegacyPostsResponse = {
posts: Array<GhostPost>; posts: Array<LegacyPost>;
}; };
export async function GET() { export async function GET() {
const cacheKey = "ghostPosts"; const cacheKey = "projects:legacyPosts";
const cachedPosts = cache.get<GhostPostsResponse>(cacheKey); const cachedPosts = cache.get<LegacyPostsResponse>(cacheKey);
if (cachedPosts) { if (cachedPosts) {
return NextResponse.json(cachedPosts); return NextResponse.json(cachedPosts);
} }
try { try {
const fetchFn = await getFetch(); const projects = await prisma.project.findMany({
const response = await (fetchFn as unknown as typeof fetch)( where: { published: true },
`${GHOST_API_URL}/ghost/api/content/posts/?key=${GHOST_API_KEY}&limit=all`, orderBy: { updatedAt: "desc" },
); select: {
const posts: GhostPostsResponse = id: true,
(await response.json()) as GhostPostsResponse; slug: true,
title: true,
updatedAt: true,
metaDescription: true,
},
});
if (!posts || !posts.posts) { const payload: LegacyPostsResponse = {
console.error("Invalid posts data"); posts: projects.map((p) => ({
return NextResponse.json([]); 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 cache.set(cacheKey, payload);
return NextResponse.json(payload);
return NextResponse.json(posts);
} catch (error) { } catch (error) {
console.error("Failed to fetch posts from Ghost:", error); console.error("Failed to fetch projects:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch projects" }, { error: "Failed to fetch projects" },
{ status: 500 }, { status: 500 },

View File

@@ -1,10 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs"; // Force Node runtime 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) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug"); const slug = searchParams.get("slug");
@@ -14,59 +12,37 @@ export async function GET(request: Request) {
} }
try { 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 */ if (!project) {
console.log( return NextResponse.json({ posts: [] }, { status: 200 });
"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 (!response || typeof response.ok === "undefined") { // Legacy shape (Ghost-like) for compatibility with older frontend/tests.
try { return NextResponse.json({
const mod = await import("node-fetch"); posts: [
const nodeFetch = (mod as any).default ?? mod; {
response = await (nodeFetch as any)( id: String(project.id),
`${GHOST_API_URL}/ghost/api/content/posts/slug/${slug}/?key=${GHOST_API_KEY}`, title: project.title,
); meta_description: project.metaDescription ?? project.description ?? "",
} catch (_err) { slug: project.slug,
response = undefined; updated_at: (project.updatedAt ?? new Date()).toISOString(),
} },
} ],
/* 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);
} catch (error) { } catch (error) {
console.error("Failed to fetch post from Ghost:", error); console.error("Failed to fetch project:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch project" }, { error: "Failed to fetch project" },
{ status: 500 }, { status: 500 },

View File

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

View File

@@ -1,164 +1,22 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { generateSitemapXml, getSitemapEntries } from "@/lib/sitemap";
interface Project {
slug: string;
updated_at?: string; // Optional timestamp for last modification
}
interface ProjectsData {
posts: Project[];
}
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; // Force Node runtime export const runtime = "nodejs";
// 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 async function GET() { export async function GET() {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; try {
const entries = await getSitemapEntries();
// Statische Routen const xml = generateSitemapXml(entries);
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, { return new NextResponse(xml, {
headers: { "Content-Type": "application/xml" }, headers: { "Content-Type": "application/xml" },
}); });
}
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) { } catch (error) {
console.log("Failed to fetch posts from Ghost:", error); console.error("Failed to generate sitemap:", error);
// Rückgabe der statischen Routen, falls Fehler auftritt // Fail closed: return minimal sitemap
return new NextResponse(generateXml(staticRoutes), { const xml = generateSitemapXml([]);
return new NextResponse(xml, {
status: 500,
headers: { "Content-Type": "application/xml" }, headers: { "Content-Type": "application/xml" },
}); });
} }

View File

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

View File

@@ -26,21 +26,21 @@ const KernelPanic404 = dynamic(() => import("./components/KernelPanic404Wrapper"
}); });
export default function NotFound() { 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); const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true); 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) { if (!mounted) {
return ( return (
<div style={{ <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 { NextResponse } from "next/server";
import { generateSitemapXml, getSitemapEntries } from "@/lib/sitemap";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET() { 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 { try {
// Holt die Sitemap-Daten von der API const entries = await getSitemapEntries();
// Try global fetch first, then fall back to node-fetch const xml = generateSitemapXml(entries);
/* 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
return new NextResponse(xml, { return new NextResponse(xml, {
headers: { "Content-Type": "application/xml" }, headers: { "Content-Type": "application/xml" },
}); });
} catch (error) { } catch (error) {
console.error("Error fetching sitemap:", error); console.error("Error generating sitemap.xml:", error);
return new NextResponse("Error fetching sitemap", { status: 500 }); return new NextResponse(generateSitemapXml([]), {
status: 500,
headers: { "Content-Type": "application/xml" },
});
} }
} }

View File

@@ -18,6 +18,9 @@ services:
- MY_PASSWORD=${MY_PASSWORD} - MY_PASSWORD=${MY_PASSWORD}
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD} - MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:your_secure_password_here} - 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 - LOG_LEVEL=info
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-} - N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-} - N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}

View File

@@ -18,6 +18,8 @@ services:
- MY_PASSWORD=${MY_PASSWORD} - MY_PASSWORD=${MY_PASSWORD}
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD} - MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:testing_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 - LOG_LEVEL=info
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-} - N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-} - N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}

View File

@@ -41,6 +41,7 @@ Dann SSL Zertifikate (Lets Encrypt) aktivieren.
- `MY_PASSWORD` - `MY_PASSWORD`
- `MY_INFO_PASSWORD` - `MY_INFO_PASSWORD`
- `ADMIN_BASIC_AUTH` (z.B. `admin:<starkes_passwort>`) - `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` - optional: `N8N_SECRET_TOKEN`
## Docker Compose Files ## 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. 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.describe('Critical Paths', () => {
test('Home page loads and displays correctly', async ({ page }) => { 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 // Wait for page to be fully loaded
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('domcontentloaded');
@@ -25,7 +25,7 @@ test.describe('Critical Paths', () => {
}); });
test('Projects page loads and displays projects', async ({ page }) => { 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 // Wait for projects to load
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('domcontentloaded');
@@ -45,7 +45,7 @@ test.describe('Critical Paths', () => {
test('Individual project page loads', async ({ page }) => { test('Individual project page loads', async ({ page }) => {
// First, get a project slug from the projects 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'); await page.waitForLoadState('domcontentloaded');
// Try to find a project link // Try to find a project link

View File

@@ -20,7 +20,7 @@ test.describe('Hydration Tests', () => {
}); });
// Navigate to home page // Navigate to home page
await page.goto('/', { waitUntil: 'networkidle' }); await page.goto('/en', { waitUntil: 'networkidle' });
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('domcontentloaded');
// Check for hydration errors // Check for hydration errors
@@ -51,7 +51,7 @@ test.describe('Hydration Tests', () => {
} }
}); });
await page.goto('/'); await page.goto('/en');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// Check for duplicate key warnings // 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'); await page.waitForLoadState('domcontentloaded');
// Navigate to projects page via link // 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) { if (await projectsLink.count() > 0) {
await projectsLink.click(); await projectsLink.click();
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('domcontentloaded');
@@ -90,7 +90,7 @@ test.describe('Hydration Tests', () => {
}); });
test('Server and client HTML match', async ({ page }) => { test('Server and client HTML match', async ({ page }) => {
await page.goto('/'); await page.goto('/en');
// Get initial HTML // Get initial HTML
const initialHTML = await page.content(); const initialHTML = await page.content();
@@ -108,7 +108,7 @@ test.describe('Hydration Tests', () => {
}); });
test('Interactive elements work after hydration', async ({ page }) => { test('Interactive elements work after hydration', async ({ page }) => {
await page.goto('/'); await page.goto('/en');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// Try to find and click interactive elements // 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 # JWT_SECRET=your-jwt-secret
# ENCRYPTION_KEY=your-encryption-key # ENCRYPTION_KEY=your-encryption-key
ADMIN_BASIC_AUTH=admin:your_secure_password_here 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) # Monitoring (optional)
# SENTRY_DSN=your-sentry-dsn # 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'; import { getRequestConfig } from "next-intl/server";
import { locales } from "./locales";
export const locales = ['en', 'de'] as const; export { locales, type AppLocale } from "./locales";
export type AppLocale = (typeof locales)[number];
export default getRequestConfig(async ({ locale }) => { export default getRequestConfig(async ({ locale }) => {
// next-intl can call us with unknown/undefined locales; fall back safely // next-intl can call us with unknown/undefined locales; fall back safely

View File

@@ -1,4 +1,5 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import type { Prisma } from "@prisma/client";
export async function getSiteSettings() { export async function getSiteSettings() {
return prisma.siteSettings.findUnique({ where: { id: 1 } }); return prisma.siteSettings.findUnique({ where: { id: 1 } });
@@ -55,14 +56,14 @@ export async function upsertContentByKey(opts: {
locale, locale,
title: title ?? undefined, title: title ?? undefined,
slug: slug ?? undefined, slug: slug ?? undefined,
content: content as any, // JSON content: content as Prisma.InputJsonValue, // JSON
metaDescription: metaDescription ?? undefined, metaDescription: metaDescription ?? undefined,
keywords: keywords ?? undefined, keywords: keywords ?? undefined,
}, },
update: { update: {
title: title ?? undefined, title: title ?? undefined,
slug: slug ?? undefined, slug: slug ?? undefined,
content: content as any, // JSON content: content as Prisma.InputJsonValue, // JSON
metaDescription: metaDescription ?? undefined, metaDescription: metaDescription ?? undefined,
keywords: keywords ?? 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=( REQUIRED_FILES=(
"CHANGELOG_DEV.md" "CHANGELOG_DEV.md"
"AFTER_PUSH_SETUP.md" "AFTER_PUSH_SETUP.md"
"prisma/migrations/create_activity_status.sql" "prisma/migrations/migration_lock.toml"
"docs/ai-image-generation/README.md" "docs/ai-image-generation/README.md"
) )
MISSING=0 MISSING=0

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/** /**
* Container entrypoint: apply Prisma migrations, then start Next server. * Container entrypoint: apply Prisma migrations, then start Next server.
* *
@@ -9,6 +10,8 @@
* - Set `SKIP_PRISMA_MIGRATE=true` to skip migrations (emergency / debugging). * - Set `SKIP_PRISMA_MIGRATE=true` to skip migrations (emergency / debugging).
*/ */
const { spawnSync } = require("node:child_process"); const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
function run(cmd, args, opts = {}) { function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, { const res = spawnSync(cmd, args, {
@@ -28,11 +31,36 @@ function run(cmd, args, opts = {}) {
const skip = String(process.env.SKIP_PRISMA_MIGRATE || "").toLowerCase() === "true"; const skip = String(process.env.SKIP_PRISMA_MIGRATE || "").toLowerCase() === "true";
if (!skip) { if (!skip) {
const autoBaseline =
String(process.env.PRISMA_AUTO_BASELINE || "").toLowerCase() === "true";
// Avoid relying on `npx` resolution in minimal runtimes. // Avoid relying on `npx` resolution in minimal runtimes.
// We copy `node_modules/prisma` into the runtime image. // 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"]); run("node", ["node_modules/prisma/build/index.js", "migrate", "deploy"]);
} else { } else {
// eslint-disable-next-line no-console
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy"); console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy");
} }

View File

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