Checkpoint before follow-up message
Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
name: Dev Deployment (Zero Downtime)
|
||||
name: Testing Deployment (Zero Downtime)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ dev ]
|
||||
branches: [ testing ]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
DOCKER_IMAGE: portfolio-app
|
||||
IMAGE_TAG: staging
|
||||
IMAGE_TAG: testing
|
||||
|
||||
jobs:
|
||||
deploy-dev:
|
||||
deploy-testing:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
echo "🏗️ Building dev Docker image with BuildKit cache..."
|
||||
echo "🏗️ Building testing Docker image with BuildKit cache..."
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
||||
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
|
||||
@@ -46,12 +46,12 @@ jobs:
|
||||
.
|
||||
echo "✅ Docker image built successfully"
|
||||
|
||||
- name: Zero-Downtime Dev Deployment
|
||||
- name: Zero-Downtime Testing Deployment
|
||||
run: |
|
||||
echo "🚀 Starting zero-downtime dev deployment..."
|
||||
echo "🚀 Starting zero-downtime testing deployment..."
|
||||
|
||||
COMPOSE_FILE="docker-compose.staging.yml"
|
||||
CONTAINER_NAME="portfolio-app-staging"
|
||||
COMPOSE_FILE="docker-compose.testing.yml"
|
||||
CONTAINER_NAME="portfolio-app-testing"
|
||||
HEALTH_PORT="3002"
|
||||
|
||||
# Backup current container ID if running
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
# Start new container with updated image
|
||||
echo "🆕 Starting new dev container..."
|
||||
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-staging
|
||||
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-testing
|
||||
|
||||
# Wait for new container to be healthy
|
||||
echo "⏳ Waiting for new container to be healthy..."
|
||||
@@ -84,8 +84,8 @@ jobs:
|
||||
|
||||
# Verify new container is working
|
||||
if ! curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
||||
echo "⚠️ New dev container health check failed, but continuing (non-blocking)..."
|
||||
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-staging
|
||||
echo "⚠️ New testing container health check failed, but continuing (non-blocking)..."
|
||||
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-testing
|
||||
fi
|
||||
|
||||
# Remove old container if it exists and is different
|
||||
@@ -98,11 +98,11 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Dev deployment completed!"
|
||||
echo "✅ Testing deployment completed!"
|
||||
env:
|
||||
NODE_ENV: staging
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_TESTING || 'https://testing.dk0.dev' }}
|
||||
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
||||
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
||||
@@ -111,19 +111,19 @@ jobs:
|
||||
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
||||
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
||||
|
||||
- name: Dev Health Check
|
||||
- name: Testing Health Check
|
||||
run: |
|
||||
echo "🔍 Running dev health checks..."
|
||||
echo "🔍 Running testing health checks..."
|
||||
for i in {1..20}; do
|
||||
if curl -f http://localhost:3002/api/health && curl -f http://localhost:3002/ > /dev/null; then
|
||||
echo "✅ Dev is fully operational!"
|
||||
echo "✅ Testing is fully operational!"
|
||||
exit 0
|
||||
fi
|
||||
echo "⏳ Waiting for dev... ($i/20)"
|
||||
echo "⏳ Waiting for testing... ($i/20)"
|
||||
sleep 3
|
||||
done
|
||||
echo "⚠️ Dev health check failed, but continuing (non-blocking)..."
|
||||
docker compose -f docker-compose.staging.yml logs --tail=50
|
||||
echo "⚠️ Testing health check failed, but continuing (non-blocking)..."
|
||||
docker compose -f docker-compose.testing.yml logs --tail=50
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
env:
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dk0.dev' }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
|
||||
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
||||
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
||||
|
||||
@@ -10,12 +10,12 @@ In Gitea kannst du **Variables** (öffentlich) und **Secrets** (verschlüsselt)
|
||||
2. Klicke auf **Settings** (Einstellungen)
|
||||
3. Klicke auf **Variables** oder **Secrets** im linken Menü
|
||||
|
||||
## 🔑 Variablen für Production Branch
|
||||
## 🔑 Variablen für Production Branch (`production` → `dk0.dev`)
|
||||
|
||||
Für den `production` Branch brauchst du:
|
||||
|
||||
### Variables (öffentlich sichtbar):
|
||||
- `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev`
|
||||
- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev`
|
||||
- `MY_EMAIL` = `contact@dk0.dev` (oder deine Email)
|
||||
- `MY_INFO_EMAIL` = `info@dk0.dev` (oder deine Info-Email)
|
||||
- `LOG_LEVEL` = `info`
|
||||
@@ -29,10 +29,10 @@ Für den `production` Branch brauchst du:
|
||||
|
||||
## 🧪 Variablen für Dev Branch
|
||||
|
||||
Für den `dev` Branch brauchst du die **gleichen** Variablen, aber mit anderen Werten:
|
||||
Für den `testing` Branch brauchst du die **gleichen** Variablen, aber mit anderen Werten:
|
||||
|
||||
### Variables:
|
||||
- `NEXT_PUBLIC_BASE_URL` = `https://dev.dk0.dev` ⚠️ **WICHTIG: Andere URL!**
|
||||
- `NEXT_PUBLIC_BASE_URL_TESTING` = `https://testing.dk0.dev` ⚠️ **WICHTIG: Andere URL!**
|
||||
- `MY_EMAIL` = `contact@dk0.dev` (kann gleich sein)
|
||||
- `MY_INFO_EMAIL` = `info@dk0.dev` (kann gleich sein)
|
||||
- `LOG_LEVEL` = `debug` (für Dev mehr Logging)
|
||||
@@ -41,7 +41,7 @@ Für den `dev` Branch brauchst du die **gleichen** Variablen, aber mit anderen W
|
||||
### Secrets:
|
||||
- `MY_PASSWORD` = Dein Email-Passwort (kann gleich sein)
|
||||
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort (kann gleich sein)
|
||||
- `ADMIN_BASIC_AUTH` = `admin:staging_password` (kann anders sein)
|
||||
- `ADMIN_BASIC_AUTH` = `admin:testing_password` (kann anders sein)
|
||||
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
|
||||
|
||||
## ✅ Lösung: Automatische Branch-Erkennung
|
||||
@@ -54,38 +54,27 @@ Die Workflows triggern auf unterschiedlichen Branches und verwenden automatisch
|
||||
|
||||
**Production Workflow** (`.gitea/workflows/production-deploy.yml`):
|
||||
- Triggert nur auf `production` Branch
|
||||
- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dk0.dev`
|
||||
- Verwendet: `NEXT_PUBLIC_BASE_URL_PRODUCTION` (wenn gesetzt) oder Default: `https://dk0.dev`
|
||||
|
||||
**Dev Workflow** (`.gitea/workflows/dev-deploy.yml`):
|
||||
- Triggert nur auf `dev` Branch
|
||||
- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dev.dk0.dev`
|
||||
**Testing Workflow** (`.gitea/workflows/dev-deploy.yml`):
|
||||
- Triggert nur auf `testing` Branch
|
||||
- Verwendet: `NEXT_PUBLIC_BASE_URL_TESTING` (wenn gesetzt) oder Default: `https://testing.dk0.dev`
|
||||
|
||||
**Das bedeutet:**
|
||||
- Du setzt **eine** Variable `NEXT_PUBLIC_BASE_URL` in Gitea
|
||||
- **Production Branch** → verwendet diese Variable (oder Default `https://dk0.dev`)
|
||||
- **Dev Branch** → verwendet diese Variable (oder Default `https://dev.dk0.dev`)
|
||||
- Du setzt **zwei** Variablen in Gitea (empfohlen, weil Gitea nicht branch-spezifisch scoped):
|
||||
- `NEXT_PUBLIC_BASE_URL_PRODUCTION`
|
||||
- `NEXT_PUBLIC_BASE_URL_TESTING`
|
||||
|
||||
### ⚠️ WICHTIG:
|
||||
|
||||
Da beide Workflows die **gleiche Variable** verwenden, aber unterschiedliche Defaults haben:
|
||||
|
||||
**Option 1: Variable NICHT setzen (Empfohlen)**
|
||||
- Production verwendet automatisch: `https://dk0.dev`
|
||||
- Dev verwendet automatisch: `https://dev.dk0.dev`
|
||||
- ✅ Funktioniert perfekt ohne Konfiguration!
|
||||
|
||||
**Option 2: Variable setzen**
|
||||
- Wenn du `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev` setzt
|
||||
- Dann verwendet **beide** Branches diese URL (nicht ideal für Dev)
|
||||
- ⚠️ Nicht empfohlen, da Dev dann die Production-URL verwendet
|
||||
Gitea kann Variablen/Secrets nicht pro Branch trennen. Darum nutzen wir **separate Variablennamen** für Production und Testing.
|
||||
|
||||
## ✅ Empfohlene Konfiguration
|
||||
|
||||
### ⭐ Einfachste Lösung: NICHTS setzen!
|
||||
### ⭐ Einfachste Lösung: nur URLs setzen (optional)
|
||||
|
||||
Die Workflows haben bereits die richtigen Defaults:
|
||||
- **Production Branch** → automatisch `https://dk0.dev`
|
||||
- **Dev Branch** → automatisch `https://dev.dk0.dev`
|
||||
- **Testing Branch** → automatisch `https://testing.dk0.dev`
|
||||
|
||||
Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch!
|
||||
|
||||
@@ -102,8 +91,9 @@ Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch!
|
||||
- `ADMIN_BASIC_AUTH` = `admin:dein_passwort`
|
||||
- `N8N_SECRET_TOKEN` = Dein n8n Token (optional)
|
||||
|
||||
**⚠️ NICHT setzen:**
|
||||
- `NEXT_PUBLIC_BASE_URL` - Lass diese Variable leer, damit jeder Branch seinen eigenen Default verwendet!
|
||||
**Optional setzen:**
|
||||
- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev`
|
||||
- `NEXT_PUBLIC_BASE_URL_TESTING` = `https://testing.dk0.dev`
|
||||
|
||||
## 📝 Schritt-für-Schritt Anleitung
|
||||
|
||||
@@ -116,11 +106,17 @@ https://git.dk0.dev/denshooter/portfolio/settings
|
||||
|
||||
### 3. Für Variables (öffentlich):
|
||||
- Klicke auf **"New Variable"**
|
||||
- **Name:** `NEXT_PUBLIC_BASE_URL`
|
||||
- **Value:** `https://dk0.dev` (für Production)
|
||||
- **Name:** `NEXT_PUBLIC_BASE_URL_PRODUCTION`
|
||||
- **Value:** `https://dk0.dev`
|
||||
- **Protect:** ✅ (optional, schützt vor Änderungen)
|
||||
- Klicke **"Add Variable"**
|
||||
|
||||
- Klicke auf **"New Variable"**
|
||||
- **Name:** `NEXT_PUBLIC_BASE_URL_TESTING`
|
||||
- **Value:** `https://testing.dk0.dev`
|
||||
- **Protect:** ✅ (optional)
|
||||
- Klicke **"Add Variable"**
|
||||
|
||||
### 4. Für Secrets (verschlüsselt):
|
||||
- Klicke auf **"New Secret"**
|
||||
- **Name:** `MY_PASSWORD`
|
||||
@@ -133,53 +129,22 @@ Die Workflows verwenden diese einfache Logik:
|
||||
|
||||
```yaml
|
||||
# Production Workflow (triggert nur auf production branch)
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dk0.dev' }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
|
||||
|
||||
# Dev Workflow (triggert nur auf dev branch)
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }}
|
||||
# Testing Workflow (triggert nur auf testing branch)
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_TESTING || 'https://testing.dk0.dev' }}
|
||||
```
|
||||
|
||||
**Das bedeutet:**
|
||||
- Jeder Workflow hat seinen **eigenen Default**
|
||||
- Wenn `NEXT_PUBLIC_BASE_URL` in Gitea gesetzt ist, wird diese verwendet
|
||||
- Wenn die jeweilige Variable (`NEXT_PUBLIC_BASE_URL_PRODUCTION` / `NEXT_PUBLIC_BASE_URL_TESTING`) in Gitea gesetzt ist, wird diese verwendet
|
||||
- Wenn **nicht** gesetzt, verwendet jeder Branch seinen eigenen Default
|
||||
|
||||
**⭐ Beste Lösung:**
|
||||
- **NICHT** `NEXT_PUBLIC_BASE_URL` in Gitea setzen
|
||||
- Dann verwendet Production automatisch `https://dk0.dev`
|
||||
- Und Dev verwendet automatisch `https://dev.dk0.dev`
|
||||
- ✅ Perfekt getrennt, ohne Konfiguration!
|
||||
- Setze `NEXT_PUBLIC_BASE_URL_PRODUCTION` und `NEXT_PUBLIC_BASE_URL_TESTING` (oder lass beide weg und nutze Defaults).
|
||||
|
||||
## 🎯 Best Practice
|
||||
|
||||
1. **Production:** Setze alle Variablen explizit in Gitea
|
||||
2. **Dev:** Nutze die Defaults im Workflow (oder setze separate Variablen)
|
||||
3. **Secrets:** Immer in Gitea Secrets setzen, nie in Code!
|
||||
|
||||
## 🔍 Prüfen ob Variablen gesetzt sind
|
||||
|
||||
In den Workflow-Logs siehst du:
|
||||
```
|
||||
📝 Using Gitea Variables and Secrets:
|
||||
- NEXT_PUBLIC_BASE_URL: https://dk0.dev
|
||||
```
|
||||
|
||||
Wenn eine Variable fehlt, wird der Default verwendet.
|
||||
|
||||
## ⚙️ Alternative: Environment-spezifische Variablen
|
||||
|
||||
Falls du separate Variablen für Dev und Production willst, können wir die Workflows anpassen:
|
||||
|
||||
```yaml
|
||||
# Production
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
|
||||
|
||||
# Dev
|
||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }}
|
||||
```
|
||||
|
||||
Dann könntest du setzen:
|
||||
- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev`
|
||||
- `NEXT_PUBLIC_BASE_URL_DEV` = `https://dev.dk0.dev`
|
||||
|
||||
Soll ich die Workflows entsprechend anpassen?
|
||||
1. **Production (`production`)**: `docker-compose.production.yml` → Port 3000 → NPM Host `dk0.dev`
|
||||
2. **Testing (`testing`)**: `docker-compose.testing.yml` → Port 3002 → NPM Host `testing.dk0.dev`
|
||||
3. **Secrets**: immer als Gitea Secrets, nie im Code
|
||||
|
||||
@@ -48,8 +48,10 @@ npm run start # Production Server
|
||||
## 📖 Dokumentation
|
||||
|
||||
- [Development Setup](DEV-SETUP.md) - Detaillierte Setup-Anleitung
|
||||
- [Deployment Guide](DEPLOYMENT.md) - Production Deployment
|
||||
- [Deployment Setup](DEPLOYMENT_SETUP.md) - Production Deployment
|
||||
- [Analytics](ANALYTICS.md) - Analytics und Performance
|
||||
- [CMS Guide](docs/CMS_GUIDE.md) - Inhalte/Sprachen pflegen (Rich Text)
|
||||
- [Testing & Deployment](docs/TESTING_AND_DEPLOYMENT.md) - Branches → Container → Domains
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { projectService } from '@/lib/prisma';
|
||||
import { prisma, projectService } from '@/lib/prisma';
|
||||
import { requireSessionAuth } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -9,16 +9,39 @@ export async function GET(request: NextRequest) {
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Get all projects with full data
|
||||
const projectsResult = await projectService.getAllProjects();
|
||||
// Projects (with translations)
|
||||
const projectsResult = await projectService.getAllProjects({ limit: 10000 });
|
||||
const projects = projectsResult.projects || projectsResult;
|
||||
const projectIds = projects.map((p: { id: number }) => p.id);
|
||||
|
||||
const projectTranslations = await prisma.projectTranslation.findMany({
|
||||
where: { projectId: { in: projectIds } },
|
||||
orderBy: [{ projectId: 'asc' }, { locale: 'asc' }],
|
||||
});
|
||||
|
||||
// CMS content pages (with translations)
|
||||
const contentPages = await prisma.contentPage.findMany({
|
||||
orderBy: { key: 'asc' },
|
||||
include: {
|
||||
translations: {
|
||||
orderBy: { locale: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const siteSettings = await prisma.siteSettings.findUnique({ where: { id: 1 } });
|
||||
|
||||
// Format for export
|
||||
const exportData = {
|
||||
version: '1.0',
|
||||
version: '2.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
siteSettings,
|
||||
contentPages,
|
||||
projectTranslations,
|
||||
projects: projects.map(project => ({
|
||||
id: project.id,
|
||||
slug: (project as unknown as { slug?: string }).slug,
|
||||
defaultLocale: (project as unknown as { defaultLocale?: string }).defaultLocale,
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
content: project.content,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { projectService } from '@/lib/prisma';
|
||||
import { prisma, projectService } from '@/lib/prisma';
|
||||
import { requireSessionAuth } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -25,10 +25,65 @@ export async function POST(request: NextRequest) {
|
||||
errors: [] as string[]
|
||||
};
|
||||
|
||||
// Import SiteSettings (optional)
|
||||
if (body.siteSettings && typeof body.siteSettings === 'object') {
|
||||
try {
|
||||
await prisma.siteSettings.upsert({
|
||||
where: { id: 1 },
|
||||
create: { id: 1, ...(body.siteSettings as Record<string, unknown>) } as any,
|
||||
update: { ...(body.siteSettings as Record<string, unknown>) } as any,
|
||||
});
|
||||
} catch {
|
||||
// non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
// Import CMS content pages (optional)
|
||||
if (Array.isArray(body.contentPages)) {
|
||||
for (const page of body.contentPages) {
|
||||
try {
|
||||
if (!page?.key) continue;
|
||||
const upserted = await prisma.contentPage.upsert({
|
||||
where: { key: page.key },
|
||||
create: { key: page.key, status: page.status || 'PUBLISHED' },
|
||||
update: { status: page.status || 'PUBLISHED' },
|
||||
});
|
||||
|
||||
if (Array.isArray(page.translations)) {
|
||||
for (const tr of page.translations) {
|
||||
if (!tr?.locale || !tr?.content) continue;
|
||||
await prisma.contentPageTranslation.upsert({
|
||||
where: { pageId_locale: { pageId: upserted.id, locale: tr.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,
|
||||
update: {
|
||||
title: tr.title || null,
|
||||
slug: tr.slug || null,
|
||||
content: tr.content,
|
||||
metaDescription: tr.metaDescription || null,
|
||||
keywords: tr.keywords || null,
|
||||
} as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push(`Failed to import content page "${page?.key}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preload existing titles once (avoid O(n^2) DB reads during import)
|
||||
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));
|
||||
|
||||
// Process each project
|
||||
for (const projectData of body.projects) {
|
||||
@@ -43,7 +98,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Create new project
|
||||
await projectService.createProject({
|
||||
const created = await projectService.createProject({
|
||||
slug: projectData.slug,
|
||||
defaultLocale: projectData.defaultLocale || 'en',
|
||||
title: projectData.title,
|
||||
description: projectData.description,
|
||||
content: projectData.content,
|
||||
@@ -76,8 +133,49 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
});
|
||||
|
||||
// Import translations (optional, from export v2)
|
||||
if (Array.isArray(body.projectTranslations)) {
|
||||
for (const tr of body.projectTranslations) {
|
||||
if (!tr?.projectId || !tr?.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 matches =
|
||||
(exportedSlug && (created as any).slug === exportedSlug) ||
|
||||
(!!exportedProject?.title && (created as any).title === exportedProject.title);
|
||||
if (!matches) continue;
|
||||
|
||||
if (!tr.title || !tr.description) continue;
|
||||
await prisma.projectTranslation.upsert({
|
||||
where: { projectId_locale: { projectId: (created as any).id, locale: tr.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,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.imported++;
|
||||
existingTitles.add(projectData.title);
|
||||
if (projectData.slug) existingSlugs.add(projectData.slug);
|
||||
} catch (error) {
|
||||
results.skipped++;
|
||||
results.errors.push(`Failed to import "${projectData.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function ImportExport() {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `portfolio-projects-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.download = `portfolio-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
@@ -119,9 +119,9 @@ export default function ImportExport() {
|
||||
<div className="space-y-4">
|
||||
{/* Export Section */}
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-4">
|
||||
<h4 className="font-medium text-stone-900 mb-2">Export Projekte</h4>
|
||||
<h4 className="font-medium text-stone-900 mb-2">Backup Export (Projekte + CMS)</h4>
|
||||
<p className="text-sm text-stone-600 mb-3">
|
||||
Alle Projekte als JSON-Datei herunterladen
|
||||
Vollständiges Backup als JSON herunterladen (inkl. CMS Inhalte und Übersetzungen)
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
@@ -135,9 +135,9 @@ export default function ImportExport() {
|
||||
|
||||
{/* Import Section */}
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-4">
|
||||
<h4 className="font-medium text-stone-900 mb-2">Import Projekte</h4>
|
||||
<h4 className="font-medium text-stone-900 mb-2">Backup Import</h4>
|
||||
<p className="text-sm text-stone-600 mb-3">
|
||||
JSON-Datei mit Projekten hochladen
|
||||
JSON-Datei mit Backup hochladen (Projekte + CMS + Übersetzungen)
|
||||
</p>
|
||||
<label className="flex items-center px-4 py-2 bg-stone-900 text-white rounded-lg hover:bg-stone-800 transition-colors cursor-pointer w-fit">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
|
||||
95
docker-compose.testing.yml
Normal file
95
docker-compose.testing.yml
Normal file
@@ -0,0 +1,95 @@
|
||||
# Testing Docker Compose configuration for testing.dk0.dev
|
||||
# Runs alongside production with isolated DB/Redis and different ports.
|
||||
|
||||
services:
|
||||
portfolio-testing:
|
||||
image: portfolio-app:testing
|
||||
container_name: portfolio-app-testing
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3002:3000" # Nginx Proxy Manager -> http://HOST:3002
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://portfolio_user:portfolio_testing_pass@postgres-testing:5432/portfolio_testing_db?schema=public
|
||||
- REDIS_URL=redis://redis-testing:6379
|
||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-https://testing.dk0.dev}
|
||||
- MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
|
||||
- MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
|
||||
- MY_PASSWORD=${MY_PASSWORD}
|
||||
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
||||
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:testing_password}
|
||||
- LOG_LEVEL=info
|
||||
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
|
||||
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}
|
||||
- N8N_API_KEY=${N8N_API_KEY:-}
|
||||
volumes:
|
||||
- portfolio_testing_data:/app/.next/cache
|
||||
networks:
|
||||
- portfolio_testing_net
|
||||
- proxy
|
||||
depends_on:
|
||||
postgres-testing:
|
||||
condition: service_healthy
|
||||
redis-testing:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
postgres-testing:
|
||||
image: postgres:16-alpine
|
||||
container_name: portfolio-postgres-testing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=portfolio_testing_db
|
||||
- POSTGRES_USER=portfolio_user
|
||||
- POSTGRES_PASSWORD=portfolio_testing_pass
|
||||
volumes:
|
||||
- postgres_testing_data:/var/lib/postgresql/data
|
||||
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
|
||||
networks:
|
||||
- portfolio_testing_net
|
||||
ports:
|
||||
- "5435:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U portfolio_user -d portfolio_testing_db"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
redis-testing:
|
||||
image: redis:7-alpine
|
||||
container_name: portfolio-redis-testing
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_testing_data:/data
|
||||
networks:
|
||||
- portfolio_testing_net
|
||||
ports:
|
||||
- "6382:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
portfolio_testing_data:
|
||||
driver: local
|
||||
postgres_testing_data:
|
||||
driver: local
|
||||
redis_testing_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
portfolio_testing_net:
|
||||
driver: bridge
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
20
docs/CMS_GUIDE.md
Normal file
20
docs/CMS_GUIDE.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# CMS Guide (ohne extra Software)
|
||||
|
||||
Du brauchst **kein externes CMS**: das Projekt hat ein eingebautes, self-hosted CMS (Postgres + Admin UI).
|
||||
|
||||
## Wo ist das CMS?
|
||||
|
||||
- Öffne: `/manage`
|
||||
- Login (Admin)
|
||||
- Tab: **Content**
|
||||
|
||||
## Wie bearbeite ich Texte?
|
||||
|
||||
Im Content Tab kannst du auswählen:
|
||||
- **Page key** (z.B. `home-hero`, `home-about`, `home-contact`, `privacy-policy`, `legal-notice`)
|
||||
- **Locale** (`en` oder `de`)
|
||||
|
||||
Dann:
|
||||
- Text bearbeiten (Rich Text)
|
||||
- **Save**
|
||||
|
||||
52
docs/TESTING_AND_DEPLOYMENT.md
Normal file
52
docs/TESTING_AND_DEPLOYMENT.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Testing & Deployment (Gitea → Docker → Nginx Proxy Manager)
|
||||
|
||||
## Ziel
|
||||
|
||||
- **Production**: Branch `production` → Container `portfolio-app` → `dk0.dev` (Port `3000`)
|
||||
- **Testing**: Branch `testing` → Container `portfolio-app-testing` → `testing.dk0.dev` (Port `3002`)
|
||||
|
||||
Beide Stacks laufen parallel und sind komplett getrennt (eigene Postgres/Redis/Volumes).
|
||||
|
||||
## DNS / Nginx Proxy Manager
|
||||
|
||||
### DNS
|
||||
- Setze `A` (oder `CNAME`) Records:
|
||||
- `dk0.dev` → dein Server
|
||||
- `testing.dk0.dev` → dein Server
|
||||
|
||||
### Nginx Proxy Manager
|
||||
Lege zwei Proxy Hosts an:
|
||||
|
||||
- **`dk0.dev`**
|
||||
- Forward Hostname/IP: `127.0.0.1` (oder Server-IP)
|
||||
- Forward Port: `3000`
|
||||
|
||||
- **`testing.dk0.dev`**
|
||||
- Forward Hostname/IP: `127.0.0.1` (oder Server-IP)
|
||||
- Forward Port: `3002`
|
||||
|
||||
Dann SSL Zertifikate (Let’s Encrypt) aktivieren.
|
||||
|
||||
## Gitea Workflows
|
||||
|
||||
- `production` push → `.gitea/workflows/production-deploy.yml`
|
||||
- `testing` push → `.gitea/workflows/dev-deploy.yml` (umbenannt im Namen, Inhalt ist Testing)
|
||||
|
||||
### Benötigte Variables (Gitea)
|
||||
- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev`
|
||||
- `NEXT_PUBLIC_BASE_URL_TESTING` = `https://testing.dk0.dev`
|
||||
- optional: `MY_EMAIL`, `MY_INFO_EMAIL`, `LOG_LEVEL`, `N8N_WEBHOOK_URL`, `N8N_API_KEY`
|
||||
|
||||
### Benötigte Secrets (Gitea)
|
||||
- `MY_PASSWORD`
|
||||
- `MY_INFO_PASSWORD`
|
||||
- `ADMIN_BASIC_AUTH` (z.B. `admin:<starkes_passwort>`)
|
||||
- optional: `N8N_SECRET_TOKEN`
|
||||
|
||||
## Docker Compose Files
|
||||
|
||||
- Production: `docker-compose.production.yml` (Port 3000)
|
||||
- Testing: `docker-compose.testing.yml` (Port 3002)
|
||||
|
||||
Wenn du “dev” nicht mehr brauchst, kannst du den Branch einfach nicht mehr benutzen.
|
||||
|
||||
Reference in New Issue
Block a user