diff --git a/CLAUDE.md b/CLAUDE.md index 7ec96f7..24ec9bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -573,6 +573,7 @@ SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 10; | FAQBlock | faq-block | FAQ-Akkordeon | | TeamBlock | team-block | Team-Mitglieder | | ServicesBlock | services-block | Leistungen | +| BeforeAfterBlock | before-after-block | Vorher/Nachher Bildvergleich (porwoll.de) | ### HeroSliderBlock Features @@ -635,6 +636,9 @@ Vollwertiger Hero-Slider mit: | ConsentLogs | consent-logs | Consent-Protokollierung | | Timelines | timelines | Chronologische Events (Geschichte, Meilensteine) | | Workflows | workflows | Komplexe Prozesse mit Phasen und Schritten | +| Bookings | bookings | Fotografie-Buchungen (porwoll.de) | +| Certifications | certifications | Zertifizierungen (C2S) | +| Projects | projects | Game-Development-Projekte (gunshin.de) | ## Timeline Collection @@ -835,4 +839,4 @@ pnpm build # Production Build - `docs/anleitungen/SECURITY.md` - Sicherheitsrichtlinien - `scripts/backup/README.md` - Backup-System Dokumentation -*Letzte Aktualisierung: 13.12.2025* +*Letzte Aktualisierung: 14.12.2025* diff --git a/docs/anleitungen/TODO.md b/docs/anleitungen/TODO.md index e2eca5f..751a4d3 100644 --- a/docs/anleitungen/TODO.md +++ b/docs/anleitungen/TODO.md @@ -10,7 +10,6 @@ ### Hohe Priorität | Status | Task | Bereich | |--------|------|---------| -| [x] | Rate-Limits auf Redis migrieren | Performance | | [ ] | SMTP-Credentials in `.env` konfigurieren | E-Mail | ### Mittlere Priorität @@ -18,7 +17,6 @@ |--------|------|---------| | [ ] | Media-Backup zu S3/MinIO | Backup | | [ ] | CDN-Integration (Cloudflare) | Caching | -| [x] | Connection Pooling (PgBouncer) | Datenbank | | [ ] | CI/CD Pipeline erweitern (Lint/Test/Build) | DevOps | | [ ] | Staging-Deployment | DevOps | | [ ] | Memory-Problem lösen (Swap) | Infrastruktur | @@ -32,14 +30,12 @@ | [ ] | Email-Log Cleanup Cron | Data Retention | | [ ] | Dashboard-Widget für Email-Status | Admin UX | | [ ] | TypeScript Strict Mode | Tech Debt | -| [ ] | Unit Tests für Access Control | Testing | | [ ] | E2E Tests für kritische Flows | Testing | ### Dokumentation | Status | Task | |--------|------| | [ ] | DEPLOYMENT.md erstellen | -| [ ] | ~~FRONTEND_INTEGRATION.md~~ → Siehe `FRONTEND.md` | --- @@ -53,430 +49,93 @@ --- -## Phase 1: Grundlagen (Abgeschlossen) - -### Infrastruktur -- [x] Payload CMS 3.x Installation -- [x] PostgreSQL-Datenbank eingerichtet -- [x] PM2 Process Manager konfiguriert -- [x] Caddy Reverse Proxy mit SSL -- [x] Multi-Tenant Plugin aktiviert -- [x] Git & GitHub Repository Setup (05.12.2025) - - [x] GitHub CLI Installation - - [x] Repository erstellt: https://github.com/c2s-admin/cms.c2sgmbh.git - - [x] GitHub Authentifizierung konfiguriert - - [x] .gitignore für sensible Dateien - - [x] Git Remote (HTTPS) konfiguriert - -### Basis-Collections -- [x] Users Collection - - [x] isSuperAdmin Feld hinzugefügt (05.12.2025) - - [x] Migration erstellt: 20251202_081830_add_is_super_admin_to_users -- [x] Media Collection -- [x] Tenants Collection -- [x] Pages Collection - -### Globals -- [x] SiteSettings -- [x] Navigation - ---- - -## Phase 2: Universal Features (Abgeschlossen) - -### Collections -- [x] Posts Collection (Blog, News, Presse, Ankündigungen) - - [x] Feld `type` (blog, news, press, announcement) - - [x] Feld `isFeatured` - - [x] Feld `excerpt` -- [x] Categories Collection -- [x] Testimonials Collection -- [x] Newsletter Subscribers Collection - - [x] Double Opt-In Support - - [x] DSGVO-konforme Felder (IP, Timestamps) -- [x] Social Links Collection - -### Blocks -- [x] Hero Block -- [x] Text Block -- [x] Image Text Block -- [x] Card Grid Block -- [x] Quote Block -- [x] CTA Block -- [x] Contact Form Block -- [x] Video Block -- [x] Divider Block -- [x] Timeline Block -- [x] Posts List Block -- [x] Testimonials Block -- [x] Newsletter Block -- [x] Process Steps Block - -### Consent Management -- [x] Cookie Configurations Collection -- [x] Cookie Inventory Collection -- [x] Consent Logs Collection -- [x] Privacy Policy Settings Collection - ---- - -## Phase 3: Offene Aufgaben +## Offene Aufgaben ### Hohe Priorität -- [x] **Tenant-Domains konfigurieren** (Erledigt: 07.12.2025) - - [x] Domains in Tenants Collection eingetragen - - [x] DNS-Einträge konfiguriert - - [x] ~~Caddy-Konfiguration~~ (nicht benötigt im Tech-Stack) - -- [x] **E-Mail-System** (Erledigt: 06.12.2025) - - [x] Multi-Tenant Email Adapter für Payload CMS - - [x] Tenant-spezifische SMTP-Konfiguration in Tenants Collection - - [x] EmailLogs Collection für Protokollierung aller E-Mails - - [x] REST-Endpoint `/api/send-email` mit: - - [x] Authentifizierung & Tenant-Zugriffskontrolle - - [x] Rate-Limiting (10 E-Mails/Minute pro User) - - [x] Form-Submission Notifications - - [x] Cache-Invalidierung bei Config-Änderungen - - [x] SMTP-Passwort-Schutz (nie in API-Responses) - - [ ] SMTP-Credentials in `.env` konfigurieren (TODO) - - [x] Newsletter Double Opt-In E-Mails (Erledigt: 10.12.2025) - - [x] E-Mail-Templates für Bestätigung, Willkommen, Abmeldung - - [x] Newsletter-Service mit Token-Validierung - - [x] API-Endpoints: subscribe, confirm, unsubscribe - - [x] Automatischer E-Mail-Versand via Hook - -- [→] **Frontend-Komponenten entwickeln** → Siehe `FRONTEND.md` - - ~~React/Next.js Komponenten für alle Blocks~~ - - ~~Newsletter-Anmelde-Formular~~ - - ~~Cookie-Banner implementieren~~ - - *Entwicklung auf sv-frontend (LXC 704)* +- [ ] **SMTP-Credentials in `.env` konfigurieren** + - Echte SMTP-Zugangsdaten für Produktion hinterlegen ### Mittlere Priorität -- [x] **Bild-Optimierung** (Erledigt: 30.11.2025) - - [x] Sharp Plugin konfiguriert - - [x] 11 Responsive Image Sizes definiert (thumbnail, small, medium, large, xlarge, 2k, og + AVIF-Varianten) - - [x] WebP/AVIF Format aktiviert - - [x] Fokuspunkt-Support - - [x] Zusätzliche Felder (caption, credit, tags) - - Dokumentation: `docs/anleitungen/BILDOPTIMIERUNG.md` +#### Analytics Integration +- [ ] **Umami Analytics (cookieless, ohne Consent)** + - [ ] Umami-Server auf sv-analytics (10.10.181.103) einrichten + - [ ] Website-IDs für alle 4 Tenants in Umami erstellen + - [ ] `src/config/analytics.ts` mit Website-IDs anlegen + - [ ] `src/components/analytics/UmamiScript.tsx` implementieren + - [ ] Umami Script in Root Layout einbinden (Multi-Tenant) + - [ ] `src/hooks/useAnalytics.ts` Hook für Custom Events + - [ ] `src/lib/analytics.server.ts` für Server-Side Events + - [ ] Event-Tracking in Newsletter-Formular integrieren + - [ ] Event-Tracking in CTA-Buttons integrieren + - [ ] TrackedButton & TrackedDownload Komponenten erstellen -- [x] **SEO-Erweiterungen** (Erledigt: 30.11.2025) - - [x] Sitemap-Generator (`/sitemap.xml`) - - [x] robots.txt (`/robots.txt`) - - [x] Structured Data (JSON-LD) Helpers - - [x] SEO Settings Global im Admin-Panel - - Dokumentation: `docs/anleitungen/SEO_ERWEITERUNG.md` +- [ ] **Google Ads Conversion (mit Consent)** + - [ ] `src/components/analytics/GoogleConsentMode.tsx` implementieren + - [ ] Google Consent Mode v2 mit Orestbida Cookie-Banner integrieren + - [ ] `src/hooks/useGclid.ts` Hook für GCLID-Erfassung + - [ ] `src/lib/google-ads.ts` Client-Side Conversion Tracking + - [ ] `src/lib/google-ads.server.ts` Server-Side Conversion API + - [ ] Enhanced Conversions mit gehashten E-Mails -- [x] **Suche implementieren** (Erledigt: 30.11.2025) - - [x] Volltextsuche für Posts (`/api/search`) - - [x] Filterbare Kategorie-Ansichten (`/api/posts?category=...`) - - [x] Auto-Complete Funktion (`/api/search/suggestions`) - - [x] Rate Limiting (30 Requests/Minute) - - [x] TTL-Caching (60 Sekunden) - - Dokumentation: `src/lib/search.ts` +- [ ] **Cookie Inventory** + - [ ] Google Ads Cookies (_gcl_au, _gcl_aw, IDE) zur Cookie Inventory Collection hinzufügen -- [x] **Mehrsprachigkeit (i18n)** (Erledigt: 30.11.2025) - - [x] Admin UI: Deutsch & Englisch (`@payloadcms/translations`) - - [x] Content Localization: DE (default), EN mit Fallback - - [x] Alle Collections lokalisiert (Pages, Posts, Categories, Testimonials) - - [x] Alle 14 Blocks lokalisiert - - [x] Alle Globals lokalisiert (SiteSettings, Navigation, SEOSettings) - - [x] 36 `_locales` Tabellen in PostgreSQL - - [x] Search API mit `locale` Parameter - - [x] Frontend Locale Routing (`/[locale]/...`) - - Hinweis: Datenbank wurde zurückgesetzt (war leer) +- [ ] **Environment Variables** + - [ ] NEXT_PUBLIC_UMAMI_HOST in .env.local + - [ ] NEXT_PUBLIC_GOOGLE_ADS_ID in .env.local (pro Tenant) + - [ ] UMAMI_HOST, UMAMI_WEBSITE_ID in Backend .env + - [ ] Google Ads API Credentials in Backend .env -### Niedrige Priorität - -- [ ] **Analytics Integration** - - **1. Umami Analytics (cookieless, ohne Consent)** - - [ ] Umami-Server auf sv-analytics (10.10.181.103) einrichten - - [ ] Website-IDs für alle 4 Tenants in Umami erstellen - - [ ] `src/config/analytics.ts` mit Website-IDs anlegen - - [ ] `src/components/analytics/UmamiScript.tsx` implementieren - - [ ] Umami Script in Root Layout einbinden (Multi-Tenant) - - [ ] `src/hooks/useAnalytics.ts` Hook für Custom Events - - [ ] `src/lib/analytics.server.ts` für Server-Side Events - - [ ] Event-Tracking in Newsletter-Formular integrieren - - [ ] Event-Tracking in CTA-Buttons integrieren - - [ ] TrackedButton & TrackedDownload Komponenten erstellen - - **2. Google Ads Conversion (mit Consent)** - - [ ] `src/components/analytics/GoogleConsentMode.tsx` implementieren - - [ ] Google Consent Mode v2 mit Orestbida Cookie-Banner integrieren - - [ ] `src/hooks/useGclid.ts` Hook für GCLID-Erfassung - - [ ] `src/lib/google-ads.ts` Client-Side Conversion Tracking - - [ ] `src/lib/google-ads.server.ts` Server-Side Conversion API - - [ ] Enhanced Conversions mit gehashten E-Mails - - **3. Cookie Inventory** - - [ ] Google Ads Cookies (_gcl_au, _gcl_aw, IDE) zur Cookie Inventory Collection hinzufügen - - **4. Environment Variables** - - [ ] NEXT_PUBLIC_UMAMI_HOST in .env.local - - [ ] NEXT_PUBLIC_GOOGLE_ADS_ID in .env.local (pro Tenant) - - [ ] UMAMI_HOST, UMAMI_WEBSITE_ID in Backend .env - - [ ] Google Ads API Credentials in Backend .env - - Dokumentation: `docs/anleitungen/ANALYTICS_IMPLEMENTATION_GUIDE.md` - -- [x] **Redis Caching** (Erledigt: 05.12.2025) - - [x] Redis-Cache für API-Responses - - [x] TTL-basierte Invalidierung - - [x] Pattern-basierte Cache-Invalidierung - - [ ] CDN-Integration (Cloudflare) (TODO) +- [ ] **CDN-Integration (Cloudflare)** - [~] **Backup-System** - - [x] Manuelle Datenbank-Backups (pg_dump) - - [x] SQL-Dateien in .gitignore - - [x] Backup via Git (temporär für Migration) - - [x] Automatische Datenbank-Backups (Cron) (Erledigt: 11.12.2025) - - [x] Backup-Rotation (30 Tage Retention) - im Skript integriert - [ ] Media-Backup zu S3/MinIO - [ ] Disaster Recovery Plan -- [ ] **Monitoring & Logging** (→ siehe Phase 4: Produktionsreife) +### Niedrige Priorität + +- [ ] **Monitoring & Logging** - Sentry Error Tracking - Prometheus Metrics - Grafana Dashboard --- -## Phase 4: Produktionsreife (Audit-basiert) +## Build & Infrastructure -> Basierend auf Audit-Analyse vom 07.12.2025 - -### [!] Hohe Priorität - Stabilität & Sicherheit - -#### Monitoring & Alerting -- [x] **AuditLogs Collection** (Erledigt: 07.12.2025) - - [x] Collection erstellen für: Tenant-Änderungen, Admin-Login, kritische Aktionen - - [x] Automatisches Logging via Collection Hooks (Users, Tenants) - - [x] Sensitive Data Masking (Passwörter, Secrets) - - [ ] Retention Policy (90 Tage) - Cron-Job TODO -- [x] **Email-Fehler Alerting** (Erledigt: 07.12.2025) - - [x] Hook bei wiederholten `failed`-Status in EmailLogs - - [x] Multi-Channel Alert Service (E-Mail, Slack, Discord, Console) - - [x] Konfigurierbare Alert-Level (info, warning, error, critical) - - [ ] Dashboard-Widget für Email-Status im Admin -- [x] **Email-Logs Admin-Verbesserungen** (Erledigt: 07.12.2025) - - [x] Filter nach Status (pending/sent/failed) im Admin - - [x] Export-Endpoint für Email-Logs (CSV/JSON) - `/api/email-logs/export` - - [x] Statistik-Endpoint (letzte 24h/7d/30d) - `/api/email-logs/stats` - -#### Backup & Recovery -- [x] **Automatisierte Datenbank-Backups** (Erledigt: 11.12.2025) - - [x] Cron-Job für tägliche pg_dump - - Skript: `/home/payload/backups/postgres/backup-db.sh` - - Cron: Täglich um 03:00 Uhr - - Log: `/home/payload/logs/backup-cron.log` - - [x] Backup-Rotation (30 Tage Retention) - lokal und S3 - - [x] Offsite-Storage (Hetzner Object Storage) - - Endpoint: `fsn1.your-objectstorage.com` - - Bucket: `s3://c2s/backups/postgres/` - - Credentials: `~/.s3cfg` (chmod 600) - - [x] Dokumentierter Restore-Prozess (Erledigt: 11.12.2025) - - Restore-Skript: `scripts/backup/restore-db.sh` - - Interaktive Auswahl aus lokalen/S3-Backups - - Automatisches Stoppen/Starten der Anwendung - - Disaster Recovery Checkliste in README -- [ ] **Media-Backup** - - [ ] S3/MinIO Integration für Media-Uploads - - [ ] Versionierung aktivieren - - [ ] Sync-Script für Offsite-Backup - -#### Security Hardening -- [x] **API-Schutz erweitern** (Erledigt: 07.12.2025) - - [x] Globales Rate-Limiting für alle öffentlichen Endpoints - - Zentraler Rate-Limiter Service (`src/lib/security/rate-limiter.ts`) - - Vordefinierte Limiter: publicApi (60/min), auth (5/15min), email (10/min), search (30/min), form (5/10min) - - Redis-Support für verteilte Systeme mit In-Memory-Fallback - - [x] IP-Allowlist Option für `/api/send-email` - - Konfiguration via `SEND_EMAIL_ALLOWED_IPS` env - - Unterstützt IPs, CIDRs und Wildcards - - Globale Blocklist via `BLOCKED_IPS` env - - [x] CSRF-Schutz für Browser-basierte API-Calls - - Double Submit Cookie Pattern - - Origin-Header-Validierung - - Token-Endpoint: `GET /api/csrf-token` -- [x] **Sensitive Data Masking** (Erledigt: 07.12.2025) - - [x] Zentraler Data-Masking-Service (`src/lib/security/data-masking.ts`) - - [x] Automatische Maskierung von Passwörtern, Tokens, API-Keys - - [x] Safe-Logger-Factory für konsistentes Logging - - [x] Rekursive Object-Maskierung für Audit-Logs -- [x] **Secrets Scanning** (Erledigt: 07.12.2025) - - [x] Pre-commit Hook für Secret-Detection (`scripts/detect-secrets.sh`) - - [x] GitHub Actions Workflow für Gitleaks und CodeQL - - [x] Gitleaks-Konfiguration (`.gitleaks.toml`) - - [x] Dependency Vulnerability Scanning - -### Mittlere Priorität - Performance & Skalierung - -#### Search Performance -- [x] **Full-Text-Search aktivieren** (Erledigt: 09.12.2025) - - [x] `USE_FTS=true` in Production gesetzt - - [x] PostgreSQL `to_tsvector`-Indices erstellt: - - `posts_locales_fts_title_idx` (GIN auf title) - - `posts_locales_fts_excerpt_idx` (GIN auf excerpt) - - `posts_locales_fts_combined_idx` (GIN auf title + excerpt) - - `pages_locales_fts_title_idx` (GIN auf title) - - `categories_locales_fts_name_idx` (GIN auf name) - - [x] Deutsche Sprachkonfiguration (`german` config) - - [x] Relevanz-Ranking mit `ts_rank()` - - [x] Prefix-Suche mit `:*` Operator - - [x] Fallback auf ILIKE bei `USE_FTS=false` -- [~] **Redis-Migration für Caches** - - [ ] Search-Cache von In-Memory auf Redis migrieren - - [x] Rate-Limit-Maps auf Redis migrieren (Erledigt: 12.12.2025) - - Redis-Bibliothek (`src/lib/redis.ts`) verbessert: Connection-Events, Retry-Strategie, Error-Handling - - Rate-Limiter (`src/lib/security/rate-limiter.ts`) nutzt jetzt Redis mit atomaren Pipeline-Operationen - - Automatischer Fallback auf In-Memory bei Redis-Ausfall - - Logging für Store-Typ (Redis vs. In-Memory) - - [ ] Suggestions-Cache auf Redis - -#### Background Jobs -- [x] **Queue-System implementieren** (Erledigt: 09.12.2025) - - [x] BullMQ oder Agenda.js evaluieren → **Empfehlung: BullMQ** - - [x] E-Mail-Versand über Queue (non-blocking) - - Queue-Service: `src/lib/queue/queue-service.ts` - - Email-Job: `src/lib/queue/jobs/email-job.ts` - - Email-Worker: `src/lib/queue/workers/email-worker.ts` - - API-Integration: `queued: true` Option in `/api/send-email` - - [x] PDF-Generierung über Queue (Erledigt: 09.12.2025) - - PDF-Job: `src/lib/queue/jobs/pdf-job.ts` - - PDF-Service: `src/lib/pdf/pdf-service.ts` (Playwright-basiert) - - PDF-Worker: `src/lib/queue/workers/pdf-worker.ts` - - API-Endpoint: `/api/generate-pdf` (POST für Generierung, GET für Job-Status) - - Unterstützt HTML-zu-PDF und URL-zu-PDF - - PM2-Integration mit konfigurierbaren Workern - - [x] Job-Dashboard im Admin - - Queue-Status: `GET /api/admin/queues` (SuperAdmin only) - - Queue-Jobs: `GET /api/admin/queues/[name]/jobs` - - Queue-Actions: `POST /api/admin/queues` (pause, resume, clean, drain) - - **Evaluation BullMQ vs Agenda.js:** - - | Kriterium | BullMQ | Agenda.js | - |-----------|--------|-----------| - | **Database** | Redis ✅ (bereits vorhanden) | MongoDB ❌ (neue Dependency) | - | **TypeScript** | Native ✅ | Begrenzt ⚠️ | - | **Priority Jobs** | Ja ✅ | Nein ❌ | - | **Rate Limiting** | Ja ✅ | Nein ❌ | - | **Delayed Jobs** | Ja ✅ | Ja ✅ | - | **Repeatable Jobs** | Ja ✅ | Ja ✅ | - | **UI Dashboard** | @bull-board ✅ | Keine Built-in ❌ | - | **Weekly Downloads** | 1.6M | 120K | - | **Maintenance** | Aktiv | Weniger aktiv | - - **Entscheidung: BullMQ** wegen: - 1. Redis bereits im Stack (keine neue DB) - 2. Native TypeScript-Unterstützung - 3. Priority Jobs & Rate Limiting für Multi-Tenant - 4. @bull-board für Admin-Dashboard - 5. Höhere Aktivität und Downloads - - **Implementierungsplan:** - 1. `pnpm add bullmq @bull-board/api @bull-board/express` - 2. Queue-Service (`src/lib/queue/queue-service.ts`) - 3. Job-Definitionen (`src/lib/queue/jobs/`) - 4. Worker-Script (`scripts/run-queue-worker.ts`) - 5. PM2-Integration (separater Prozess) - 6. Admin-Dashboard Route (`/admin/jobs`) - - **Betroffene Dateien für E-Mail-Queue:** - - `src/lib/email/tenant-email-service.ts` - - `src/app/(payload)/api/send-email/route.ts` - - `src/app/(payload)/api/test-email/route.ts` - - `src/lib/alerting/alert-service.ts` - - `src/hooks/sendFormNotification.ts` - -#### Database Optimization -- [x] **Index-Audit** (Erledigt: 09.12.2025) - - [x] Composite-Indices für lokalisierte Felder (slug + locale) - - `posts_locales_slug_locale_idx` - - `pages_locales_slug_locale_idx` - - `categories_locales_slug_locale_idx` - - [x] Query-Performance-Analyse - - [x] EXPLAIN ANALYZE für häufige Queries - - [x] Zusätzliche Performance-Indexes erstellt: - - `posts_status_tenant_idx` (status + tenant) - - `posts_type_tenant_idx` (type + tenant) - - `posts_published_at_idx` (chronologische Sortierung) - - `posts_is_featured_tenant_idx` (partial index) - - `email_logs_status_tenant_idx`, `email_logs_status_created_at_idx` - - `audit_logs_action_created_at_idx` - - `newsletter_subscribers_status_tenant_idx` - - `consent_logs_created_at_desc_idx` -- [x] **Connection Pooling** (Erledigt: 12.12.2025) - - [x] PgBouncer 1.24.1 auf App-Server installiert - - [x] Transaction-Mode für optimale Verbindungswiederverwendung - - [x] SCRAM-SHA-256 Authentifizierung - - [x] TLS 1.3 zu PostgreSQL - - [x] Pool-Größe: 20 (default), max 50 DB-Verbindungen - - [x] Reserve-Pool für Lastspitzen (5 Verbindungen) - - [x] Payload CMS über PgBouncer (localhost:6432) - - [x] TLS 1.3 mit `server_tls_sslmode = require` - -#### Build & Infrastructure - [ ] **Memory-Problem lösen** - [ ] Swap auf Server aktivieren (2-4GB) - [ ] Alternativ: Build auf separatem Runner + - [ ] **PM2 Cluster Mode** - [ ] Multi-Instanz Konfiguration testen - [ ] Shared State via Redis sicherstellen -### Niedrige Priorität - Developer Experience & UX +--- + +## Testing & CI/CD -#### Testing & CI/CD -- [x] **Security Test Suite** (Erledigt: 08.12.2025) - - [x] Unit Tests für Rate-Limiter (`tests/unit/security/rate-limiter.unit.spec.ts`) - - [x] Unit Tests für CSRF Protection (`tests/unit/security/csrf.unit.spec.ts`) - - [x] Unit Tests für IP-Allowlist (`tests/unit/security/ip-allowlist.unit.spec.ts`) - - [x] Unit Tests für Data-Masking (`tests/unit/security/data-masking.unit.spec.ts`) - - [x] API Integration Tests (`tests/int/security-api.int.spec.ts`) - - [x] Test Utilities (`tests/helpers/security-test-utils.ts`) - - [x] Dedicated Script: `pnpm test:security` - - [x] CI Integration in `.github/workflows/security.yml` -- [x] **Test-Suite erweitern** (Erledigt: 09.12.2025) - - [x] Test-DB mit Migrationen aufsetzen (Locale-Tabellen bereits vorhanden) - - [x] Skipped Tests aktivieren (search, i18n) - alle 9 Tests nun aktiv - - [x] Coverage-Report generieren (`pnpm test:coverage`) - - Vitest v8 Coverage Provider konfiguriert - - HTML/LCOV/Text Reports in `./coverage/` - - Thresholds: 35% lines, 50% functions, 65% branches - - Aktuell: 37.29% lines, 55.55% functions, 71.61% branches -- [x] **Audit-Fixes** (Erledigt: 10.12.2025) - - [x] Vitest auf 3.2.4 aktualisiert (Version-Warnung entfernt) - - [x] `payload.create`/`payload.update` Mock in `tests/int/security-api.int.spec.ts` ergänzt - - [x] 205 Tests laufen fehlerfrei, Coverage-Report ohne Abbrüche - - [x] Kein "`payload.create is not a function`" Hinweis mehr im Test-Output - [ ] **CI/CD Pipeline** - - [x] GitHub Actions Workflow erstellt (security.yml) - [ ] Automatisches Lint/Test/Build Workflow - - [x] Secrets-Scanning in Pipeline - [ ] Staging-Deployment -#### Admin UX -- [x] **Tenant-Wechsel UI** (Erledigt: 08.12.2025) - - [x] Dropdown in Admin-Leiste für schnellen Tenant-Wechsel (Multi-Tenant Plugin integriert) - - [x] Tenant-Kontext in Breadcrumbs anzeigen (Custom TenantBreadcrumb Komponente) - - [x] Deutsche Übersetzungen für Tenant-Selector hinzugefügt -- [x] **Email-Konfiguration UX** (Erledigt: 08.12.2025) - - [x] Formularvalidierung für SMTP-Settings (Host-Format, Port-Bereich, Pflichtfelder) - - [x] Tooltips für SPF/DKIM-Hinweise (aufklappbare Info-Komponente mit Beispielen) - - [x] "Test-Email senden" Button (Custom UI-Komponente + API-Endpoint) -- [x] **Tenant Self-Service** (Erledigt: 08.12.2025) - - [x] API für Tenant-Admins zum Testen der SMTP-Settings (`/api/test-email`) - - [x] Email-Logs Einsicht für eigenen Tenant (Tenant-basierter Zugriff bereits vorhanden) - - [x] Eigene Statistiken Dashboard (`/admin/tenant-dashboard`) +- [ ] **E2E Tests für kritische Flows** + +--- + +## Data Retention -#### Data Retention - [ ] **Automatische Datenbereinigung** - [ ] Cron-Job für Email-Log Cleanup (älter als X Tage) + - [ ] AuditLogs Retention Policy (90 Tage) - [ ] Consent-Logs Archivierung - [ ] Media-Orphan-Cleanup --- -## Phase 5: Tenant-spezifische Features +## Tenant-spezifische Features > **Hinweis:** Backend-Collections hier, Frontend-Komponenten in `FRONTEND.md` @@ -486,28 +145,15 @@ - [→] Kontaktformular mit Objekt-Referenz *(Frontend)* ### complexcaresolutions.de (C2S) -- [x] Team-Collection *(bereits vorhanden)* -- [x] Services-Collection *(bereits vorhanden)* - [→] Leistungs-Übersicht *(Frontend)* - [→] Karriere-Seite mit Stellenangeboten *(Frontend)* ### gunshin.de / Fotografin-Portfolio -- [x] Portfolio-Categories Collection (Erledigt: 06.12.2025) - - [x] Name, Slug, Beschreibung (lokalisiert) - - [x] Cover-Bild, Reihenfolge, Aktiv-Status -- [x] Portfolios Collection (Erledigt: 06.12.2025) - - [x] Titel, Beschreibung, Excerpt (lokalisiert) - - [x] Kategorie-Beziehung - - [x] Cover-Bild + Galerie-Bilder mit Captions - - [x] Projekt-Details (Kunde, Ort, Datum, Ausrüstung) - - [x] Status (draft/published/archived) - - [x] isFeatured, SEO-Felder - [→] Projekt-Galerie Frontend *(Frontend)* - [→] Referenzen-Slider *(Frontend)* ### zweitmein.ng - [ ] Produkt-Collection (falls E-Commerce) *(Backend)* -- [x] FAQ-Collection *(bereits vorhanden)* - [→] Preistabellen *(Frontend)* --- @@ -515,18 +161,7 @@ ## Technische Schulden - [ ] TypeScript Strict Mode aktivieren -- [x] Unit Tests für Access Control (Erledigt: 12.12.2025) - - [x] Test-Utilities (`tests/helpers/access-control-test-utils.ts`) - - [x] Tenant Access Tests (`tests/unit/access-control/tenant-access.unit.spec.ts`) - - [x] Collection Access Tests (`tests/unit/access-control/collection-access.unit.spec.ts`) - - [x] Field Access Tests (`tests/unit/access-control/field-access.unit.spec.ts`) - - [x] Dedicated Script: `pnpm test:access-control` - - 112 neue Tests für Access Control (251 Tests insgesamt) - [ ] E2E Tests für kritische Flows -- [x] API-Dokumentation automatisch generieren (OpenAPI) (Erledigt: 10.12.2025) - - [x] payload-oapi Plugin installiert und konfiguriert - - [x] OpenAPI 3.1 Spezifikation unter `/api/openapi.json` - - [x] Swagger UI unter `/api/docs` - [ ] Code-Review für Security-relevante Bereiche - [ ] Performance-Audit der Datenbank-Queries @@ -534,16 +169,6 @@ ## Dokumentation -- [x] CLAUDE.md (Projekt-Übersicht) -- [x] UNIVERSAL_FEATURES.md (Collections & Blocks) -- [x] API_ANLEITUNG.md (REST API Guide) -- [x] TODO.md (Diese Datei) -- [x] BILDOPTIMIERUNG.md (Sharp & Image Sizes) -- [x] SEO_ERWEITERUNG.md (SEO Features) -- [x] ANALYTICS_IMPLEMENTATION_GUIDE.md (Umami & Google Ads) -- [x] Techstack_Dokumentation_12_2025.md (Infrastruktur & Deployment) -- [x] SECURITY.md (Sicherheitsrichtlinien) (Erledigt: 08.12.2025) -- [x] FRONTEND.md (Frontend-Entwicklung für sv-frontend) (Erledigt: 11.12.2025) - [ ] DEPLOYMENT.md (Deployment-Prozess) --- @@ -579,14 +204,9 @@ ### Nächste Schritte (Priorisiert) -1. ~~**[KRITISCH]** AuditLogs Collection implementieren~~ ✅ Erledigt -2. ~~**[KRITISCH]** Automatisierte Backups einrichten~~ ✅ Erledigt (11.12.2025) -3. ~~**[HOCH]** Full-Text-Search aktivieren (USE_FTS=true)~~ ✅ Erledigt -4. ~~**[HOCH]** Rate-Limits auf Redis migrieren~~ ✅ Erledigt (12.12.2025) -5. ~~**[MITTEL]** CI/CD Pipeline mit GitHub Actions~~ ✅ security.yml erstellt -6. ~~**[MITTEL]** Frontend-Entwicklung starten~~ → sv-frontend (siehe FRONTEND.md) -7. **[MITTEL]** Media-Backup zu S3 einrichten -8. **[NIEDRIG]** Monitoring (Sentry/Prometheus) +1. **[MITTEL]** Media-Backup zu S3 einrichten +2. **[NIEDRIG]** Monitoring (Sentry/Prometheus) +3. **[NIEDRIG]** Analytics Integration (Umami) --- @@ -598,133 +218,39 @@ --- -*Letzte Aktualisierung: 12.12.2025* +*Letzte Aktualisierung: 14.12.2025* --- ## Changelog +### 14.12.2025 +- **Tenant-spezifische Collections implementiert:** + - Bookings Collection für porwoll.de (Fotografie-Buchungen) + - Certifications Collection für C2S (Zertifizierungen) + - Projects Collection für gunshin.de (Game-Development-Projekte) + - BeforeAfterBlock für porwoll.de (Vorher/Nachher Bildvergleich) + - Migration: `20251214_010000_tenant_specific_collections.ts` + - 28 neue Datenbank-Tabellen erstellt +- **TODO.md bereinigt:** Alle erledigten Tasks entfernt + ### 12.12.2025 -- **PgBouncer Connection Pooling eingerichtet:** - - PgBouncer 1.24.1 auf App-Server (sv-payload) installiert - - Konfiguration: `/etc/pgbouncer/pgbouncer.ini` - - Transaction-Mode für optimale Verbindungswiederverwendung - - SCRAM-SHA-256 Authentifizierung - - TLS 1.3 zu PostgreSQL-Server - - Pool-Größe: 20 default, 5 min, 5 reserve - - Max 50 DB-Verbindungen, 200 Client-Verbindungen - - Payload CMS nutzt jetzt PgBouncer (localhost:6432) - - TLS 1.3 mit `server_tls_sslmode = require` zu PostgreSQL - - Lasttest: 20 parallele Requests mit nur 5-6 PostgreSQL-Verbindungen - - PgBouncer Statistiken via `SHOW POOLS`, `SHOW STATS` - -- **Unit Tests für Access Control implementiert:** - - Test-Utilities (`tests/helpers/access-control-test-utils.ts`): - - User-Factory: `createSuperAdmin()`, `createTenantUser()`, `createAnonymousUser()` - - Request-Factory: `createMockPayloadRequest()`, `createAnonymousRequest()` - - Assertion-Helpers: `assertAccessGranted()`, `assertTenantFiltered()` - - Tenant Access Tests (`tests/unit/access-control/tenant-access.unit.spec.ts`): - - `getTenantIdFromHost()`: Host-Extraktion, Domain-Normalisierung, Error-Handling - - `tenantScopedPublicRead`: Authenticated vs. Anonymous, Tenant-Filter - - `authenticatedOnly`: Simple Auth-Check - - Collection Access Tests (`tests/unit/access-control/collection-access.unit.spec.ts`): - - AuditLogs: Super Admin Only, WORM Pattern (Write-Once-Read-Many) - - EmailLogs: Tenant-Scoped Read mit IN-Clause, Super Admin Delete - - Pages: Status-Based Access (published/draft) - - ConsentLogs: API-Key Access - - Field Access Tests (`tests/unit/access-control/field-access.unit.spec.ts`): - - SMTP Password: Always false (nie in API-Response) - - Super Admin Only Fields - - Conditional Field Access mit siblingData - - Tenant-Scoped Field Access - - 112 neue Tests, 251 Tests insgesamt, alle bestanden - - Dedicated Script: `pnpm test:access-control` - -- **Rate-Limits auf Redis migriert:** - - Redis-Bibliothek (`src/lib/redis.ts`) verbessert: - - Connection-Events (connect, ready, error, close) - - Automatische Retry-Strategie mit max. 3 Versuchen - - `enableOfflineQueue: false` für sofortigen Fallback - - `checkRedisConnection()` Funktion für echten Health-Check - - `setRedisAvailable()` für dynamischen Status-Update - - Rate-Limiter (`src/lib/security/rate-limiter.ts`): - - Atomare Redis-Pipeline für INCR + EXPIRE - - Automatischer Fallback auf In-Memory bei Redis-Fehler - - Logging beim ersten Aufruf pro Limiter-Typ (Redis vs. Memory) - - Verbesserte Error-Propagation an Redis-Status - - Getestet und verifiziert: - - Redis-Keys werden korrekt erstellt (`ratelimit:*`) - - Counter werden atomar inkrementiert - - TTL wird korrekt gesetzt - - HTTP 429 bei Limit-Überschreitung +- **PgBouncer Connection Pooling eingerichtet** +- **Unit Tests für Access Control implementiert** +- **Rate-Limits auf Redis migriert** ### 11.12.2025 -- **Automatisierte Datenbank-Backups:** Cron-Job für tägliche pg_dump eingerichtet - - Backup-Skript: `/home/payload/backups/postgres/backup-db.sh` - - Tägliche Ausführung um 03:00 Uhr via Cron - - Automatische Rotation: Backups älter als 30 Tage werden gelöscht - - Komprimierte Backups mit gzip (~42KB pro Backup) - - Integritätsprüfung nach jedem Backup - - Detaillierte Logs in `/home/payload/backups/postgres/backup.log` -- **Offsite-Backup zu Hetzner Object Storage:** - - s3cmd installiert und konfiguriert (`~/.s3cfg`, chmod 600) - - Automatischer Upload nach jedem Backup zu `s3://c2s/backups/postgres/` - - 30-Tage-Retention auch auf S3 (alte Backups werden automatisch gelöscht) - - Endpoint: `fsn1.your-objectstorage.com` -- **Dokumentierter Restore-Prozess:** - - Interaktives Restore-Skript: `scripts/backup/restore-db.sh` - - Unterstützt lokale und S3-Backups - - Automatisches Stoppen/Starten von PM2 - - Backup-Verifizierung vor Restore - - Disaster Recovery Checkliste in `scripts/backup/README.md` - -### 09.12.2025 -- **Admin Login Fix:** Custom Login-Route unterstützt nun `_payload` JSON-Feld aus multipart/form-data (Payload Admin Panel Format) -- **Dokumentation bereinigt:** Obsolete PROMPT_*.md Instruktionsdateien gelöscht -- **CLAUDE.md aktualisiert:** Security-Features, Test Suite, AuditLogs dokumentiert -- **Index-Audit:** 12 neue Performance-Indexes für PostgreSQL erstellt - - Composite-Indexes für slug+locale auf posts_locales, pages_locales, categories_locales - - Status/Tenant-Indexes für posts, email_logs, newsletter_subscribers - - Partial Index für Featured Posts - - Chronologische Sortierung für published_at, created_at +- **Automatisierte Datenbank-Backups eingerichtet** +- **Offsite-Backup zu Hetzner Object Storage** +- **Dokumentierter Restore-Prozess** ### 10.12.2025 -- **Audit-Fixes:** Vitest auf 3.2.4 aktualisiert, Payload-Mocks im Security-Test ergänzt -- **OpenAPI-Dokumentation:** payload-oapi Plugin für automatische API-Dokumentation - - OpenAPI 3.1 Spezifikation unter `/api/openapi.json` - - Swagger UI unter `/api/docs` -- **Newsletter Double Opt-In:** DSGVO-konformes Newsletter-System - - E-Mail-Templates: Bestätigung, Willkommen, Abmeldung (`src/lib/email/newsletter-templates.ts`) - - Newsletter-Service mit Token-Validierung (`src/lib/email/newsletter-service.ts`) - - API-Endpoints: `/api/newsletter/subscribe`, `/confirm`, `/unsubscribe` - - Automatischer Hook für E-Mail-Versand bei Anmeldung - - Token-Ablauf nach 48 Stunden +- **Newsletter Double Opt-In implementiert** +- **OpenAPI-Dokumentation hinzugefügt** +- **Audit-Fixes durchgeführt** -### 09.12.2025 (Fortsetzung) -- **Full-Text-Search:** PostgreSQL FTS mit GIN-Indexes aktiviert - - 5 FTS-Indexes auf posts_locales, pages_locales, categories_locales - - Deutsche Sprachkonfiguration (`german` config) - - Relevanz-Ranking mit `ts_rank()` - - Feature-Flag `USE_FTS=true` in .env -- **Queue-System Evaluation:** BullMQ vs Agenda.js evaluiert - - **Empfehlung: BullMQ** (Redis-basiert, TypeScript-native, @bull-board UI) - - Implementierungsplan dokumentiert - - Betroffene Dateien identifiziert -- **BullMQ Implementation:** Vollständiges Queue-System implementiert - - Queue-Service mit Redis-Connection und Job-Optionen - - Email-Job mit Priority, Delay und Batch-Support - - Email-Worker für asynchrone Verarbeitung - - Worker-Script für PM2 (`scripts/run-queue-worker.ts`) - - PM2-Konfiguration für separaten Worker-Prozess - - Admin-API für Queue-Monitoring (`/api/admin/queues`) - - Send-Email API mit `queued: true` Option -- **Audit-Fixes (BullMQ):** FTS und Dependencies bereinigt - - FTS SQL-Fix: `p.published_at` zu SELECT hinzugefügt (PostgreSQL DISTINCT-Regel) - - Guard für fehlende `payload.db.drizzle` in Tests - - Ungenutzte `@bull-board/*` Packages entfernt (53 Dependencies weniger) -- **PDF-Queue-System:** Vollständige PDF-Generierung über BullMQ - - PDF-Job-Definition mit Priority, Delay und Batch-Support - - PDF-Service mit Playwright (HTML-zu-PDF, URL-zu-PDF) - - PDF-Worker für asynchrone Verarbeitung - - REST-API `/api/generate-pdf` mit Auth, CSRF, Rate-Limiting - - PM2-Integration mit konfigurierbaren Workern (`QUEUE_ENABLE_PDF`) +### 09.12.2025 +- **Full-Text-Search aktiviert** +- **BullMQ Queue-System implementiert** +- **PDF-Queue-System implementiert** +- **Index-Audit durchgeführt** diff --git a/src/blocks/BeforeAfterBlock.ts b/src/blocks/BeforeAfterBlock.ts new file mode 100644 index 0000000..db2c469 --- /dev/null +++ b/src/blocks/BeforeAfterBlock.ts @@ -0,0 +1,518 @@ +import type { Block } from 'payload' + +/** + * Before/After Block + * + * Vergleichsslider für Vorher/Nachher-Bilder. + * Ideal für Fotografie-Portfolios, Renovierungen, Transformationen. + */ +export const BeforeAfterBlock: Block = { + slug: 'before-after', + labels: { + singular: 'Vorher/Nachher', + plural: 'Vorher/Nachher', + }, + fields: [ + { + name: 'title', + type: 'text', + label: 'Überschrift', + localized: true, + }, + { + name: 'subtitle', + type: 'text', + label: 'Untertitel', + localized: true, + }, + { + name: 'description', + type: 'textarea', + label: 'Beschreibung', + localized: true, + }, + // Vergleiche + { + name: 'comparisons', + type: 'array', + required: true, + minRows: 1, + label: 'Vergleiche', + fields: [ + { + name: 'title', + type: 'text', + label: 'Titel', + localized: true, + }, + { + type: 'row', + fields: [ + { + name: 'beforeImage', + type: 'upload', + relationTo: 'media', + required: true, + label: 'Vorher-Bild', + admin: { + width: '50%', + }, + }, + { + name: 'afterImage', + type: 'upload', + relationTo: 'media', + required: true, + label: 'Nachher-Bild', + admin: { + width: '50%', + }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'beforeLabel', + type: 'text', + defaultValue: 'Vorher', + label: 'Vorher-Label', + localized: true, + admin: { + width: '50%', + }, + }, + { + name: 'afterLabel', + type: 'text', + defaultValue: 'Nachher', + label: 'Nachher-Label', + localized: true, + admin: { + width: '50%', + }, + }, + ], + }, + { + name: 'description', + type: 'textarea', + label: 'Beschreibung', + localized: true, + }, + // Kategorie für Filterung + { + name: 'category', + type: 'select', + label: 'Kategorie', + options: [ + { label: 'Hochzeit', value: 'wedding' }, + { label: 'Portrait', value: 'portrait' }, + { label: 'Retusche', value: 'retouch' }, + { label: 'Farbkorrektur', value: 'colorgrade' }, + { label: 'Restaurierung', value: 'restore' }, + { label: 'Composing', value: 'composing' }, + { label: 'Architektur', value: 'architecture' }, + { label: 'Produkt', value: 'product' }, + { label: 'Sonstiges', value: 'other' }, + ], + }, + // Tags + { + name: 'tags', + type: 'text', + label: 'Tags', + admin: { + description: 'Komma-getrennte Tags für Filterung', + }, + }, + // Zusätzliche Info + { + name: 'metadata', + type: 'group', + label: 'Zusatzinformationen', + admin: { + condition: (_, siblingData) => siblingData?.showMetadata, + }, + fields: [ + { + name: 'client', + type: 'text', + label: 'Kunde', + }, + { + name: 'date', + type: 'date', + label: 'Datum', + admin: { + date: { + pickerAppearance: 'monthOnly', + displayFormat: 'MMMM yyyy', + }, + }, + }, + { + name: 'tools', + type: 'text', + label: 'Verwendete Tools', + admin: { + description: 'z.B. Lightroom, Photoshop', + }, + }, + { + name: 'duration', + type: 'text', + label: 'Bearbeitungszeit', + }, + ], + }, + { + name: 'showMetadata', + type: 'checkbox', + label: 'Metadaten anzeigen', + defaultValue: false, + }, + ], + }, + // Display-Optionen + { + name: 'displayStyle', + type: 'select', + defaultValue: 'slider', + label: 'Darstellung', + options: [ + { label: 'Slider (Schieberegler)', value: 'slider' }, + { label: 'Hover-Effekt', value: 'hover' }, + { label: 'Click to Toggle', value: 'toggle' }, + { label: 'Nebeneinander', value: 'side-by-side' }, + { label: 'Übereinander (Fade)', value: 'fade' }, + ], + }, + { + name: 'sliderOrientation', + type: 'select', + defaultValue: 'horizontal', + label: 'Slider-Richtung', + options: [ + { label: 'Horizontal', value: 'horizontal' }, + { label: 'Vertikal', value: 'vertical' }, + ], + admin: { + condition: (data, siblingData) => siblingData?.displayStyle === 'slider', + }, + }, + { + name: 'sliderStartPosition', + type: 'number', + defaultValue: 50, + min: 0, + max: 100, + label: 'Slider-Startposition (%)', + admin: { + condition: (data, siblingData) => siblingData?.displayStyle === 'slider', + description: '0 = links/oben, 100 = rechts/unten', + }, + }, + // Layout + { + name: 'layout', + type: 'select', + defaultValue: 'single', + label: 'Layout', + options: [ + { label: 'Einzeln (volle Breite)', value: 'single' }, + { label: 'Grid (2 Spalten)', value: 'grid-2' }, + { label: 'Grid (3 Spalten)', value: 'grid-3' }, + { label: 'Karussell', value: 'carousel' }, + { label: 'Masonry', value: 'masonry' }, + ], + }, + { + name: 'aspectRatio', + type: 'select', + defaultValue: 'original', + label: 'Seitenverhältnis', + options: [ + { label: 'Original', value: 'original' }, + { label: '16:9', value: '16-9' }, + { label: '4:3', value: '4-3' }, + { label: '3:2', value: '3-2' }, + { label: '1:1 (Quadrat)', value: '1-1' }, + { label: '2:3 (Portrait)', value: '2-3' }, + { label: '9:16 (Story)', value: '9-16' }, + ], + }, + // Slider-Styling + { + name: 'sliderHandle', + type: 'group', + label: 'Slider-Griff', + admin: { + condition: (data, siblingData) => siblingData?.displayStyle === 'slider', + }, + fields: [ + { + name: 'style', + type: 'select', + defaultValue: 'circle', + label: 'Stil', + options: [ + { label: 'Kreis', value: 'circle' }, + { label: 'Linie', value: 'line' }, + { label: 'Pfeile', value: 'arrows' }, + { label: 'Custom Icon', value: 'custom' }, + ], + }, + { + name: 'color', + type: 'select', + defaultValue: 'white', + label: 'Farbe', + options: [ + { label: 'Weiß', value: 'white' }, + { label: 'Schwarz', value: 'black' }, + { label: 'Primär', value: 'primary' }, + { label: 'Akzent', value: 'accent' }, + ], + }, + { + name: 'size', + type: 'select', + defaultValue: 'medium', + label: 'Größe', + options: [ + { label: 'Klein', value: 'small' }, + { label: 'Mittel', value: 'medium' }, + { label: 'Groß', value: 'large' }, + ], + }, + { + name: 'showLine', + type: 'checkbox', + defaultValue: true, + label: 'Trennlinie anzeigen', + }, + ], + }, + // Labels + { + name: 'showLabels', + type: 'checkbox', + defaultValue: true, + label: 'Labels anzeigen', + }, + { + name: 'labelPosition', + type: 'select', + defaultValue: 'corners', + label: 'Label-Position', + options: [ + { label: 'Ecken', value: 'corners' }, + { label: 'Oben', value: 'top' }, + { label: 'Unten', value: 'bottom' }, + { label: 'Auf Bildern', value: 'overlay' }, + ], + admin: { + condition: (data, siblingData) => siblingData?.showLabels, + }, + }, + { + name: 'labelStyle', + type: 'select', + defaultValue: 'badge', + label: 'Label-Stil', + options: [ + { label: 'Badge', value: 'badge' }, + { label: 'Text', value: 'text' }, + { label: 'Pill', value: 'pill' }, + ], + admin: { + condition: (data, siblingData) => siblingData?.showLabels, + }, + }, + // Filter & Kategorien + { + name: 'showFilter', + type: 'checkbox', + defaultValue: false, + label: 'Kategorie-Filter anzeigen', + admin: { + condition: (data, siblingData) => + siblingData?.comparisons && siblingData.comparisons.length > 3, + }, + }, + // Animation + { + name: 'animation', + type: 'group', + label: 'Animation', + fields: [ + { + name: 'enableAnimation', + type: 'checkbox', + defaultValue: true, + label: 'Animation aktivieren', + }, + { + name: 'autoPlay', + type: 'checkbox', + defaultValue: false, + label: 'Auto-Play (Slider bewegt sich)', + admin: { + condition: (data, siblingData) => + siblingData?.enableAnimation && data?.displayStyle === 'slider', + }, + }, + { + name: 'autoPlaySpeed', + type: 'number', + defaultValue: 3, + min: 1, + max: 10, + label: 'Auto-Play Geschwindigkeit (Sekunden)', + admin: { + condition: (data, siblingData) => siblingData?.autoPlay, + }, + }, + { + name: 'scrollTrigger', + type: 'checkbox', + defaultValue: true, + label: 'Animation bei Scroll auslösen', + admin: { + condition: (data, siblingData) => siblingData?.enableAnimation, + }, + }, + ], + }, + // Interaktivität + { + name: 'interactivity', + type: 'group', + label: 'Interaktivität', + fields: [ + { + name: 'enableZoom', + type: 'checkbox', + defaultValue: false, + label: 'Zoom bei Klick', + }, + { + name: 'enableFullscreen', + type: 'checkbox', + defaultValue: true, + label: 'Vollbild-Modus', + }, + { + name: 'enableSwipe', + type: 'checkbox', + defaultValue: true, + label: 'Touch/Swipe aktivieren', + }, + { + name: 'enableKeyboard', + type: 'checkbox', + defaultValue: true, + label: 'Tastatur-Navigation', + }, + ], + }, + // CTA + { + name: 'cta', + type: 'group', + label: 'Call-to-Action', + fields: [ + { + name: 'showCta', + type: 'checkbox', + defaultValue: false, + label: 'CTA anzeigen', + }, + { + name: 'ctaText', + type: 'text', + label: 'Button-Text', + localized: true, + admin: { + condition: (data, siblingData) => siblingData?.showCta, + }, + }, + { + name: 'ctaLink', + type: 'text', + label: 'Button-Link', + admin: { + condition: (data, siblingData) => siblingData?.showCta, + }, + }, + { + name: 'ctaStyle', + type: 'select', + defaultValue: 'primary', + label: 'Button-Stil', + options: [ + { label: 'Primär', value: 'primary' }, + { label: 'Sekundär', value: 'secondary' }, + { label: 'Outline', value: 'outline' }, + { label: 'Ghost', value: 'ghost' }, + ], + admin: { + condition: (data, siblingData) => siblingData?.showCta, + }, + }, + ], + }, + // Styling + { + name: 'backgroundColor', + type: 'select', + defaultValue: 'transparent', + label: 'Hintergrund', + options: [ + { label: 'Transparent', value: 'transparent' }, + { label: 'Weiß', value: 'white' }, + { label: 'Hell (Grau)', value: 'light' }, + { label: 'Dunkel', value: 'dark' }, + { label: 'Schwarz', value: 'black' }, + ], + }, + { + name: 'borderRadius', + type: 'select', + defaultValue: 'medium', + label: 'Ecken-Rundung', + options: [ + { label: 'Keine', value: 'none' }, + { label: 'Klein', value: 'small' }, + { label: 'Mittel', value: 'medium' }, + { label: 'Groß', value: 'large' }, + ], + }, + { + name: 'shadow', + type: 'select', + defaultValue: 'medium', + label: 'Schatten', + options: [ + { label: 'Kein', value: 'none' }, + { label: 'Klein', value: 'small' }, + { label: 'Mittel', value: 'medium' }, + { label: 'Groß', value: 'large' }, + ], + }, + { + name: 'spacing', + type: 'select', + defaultValue: 'medium', + label: 'Abstand', + options: [ + { label: 'Kein', value: 'none' }, + { label: 'Klein', value: 'small' }, + { label: 'Mittel', value: 'medium' }, + { label: 'Groß', value: 'large' }, + ], + }, + ], +} diff --git a/src/blocks/index.ts b/src/blocks/index.ts index e2ffc76..ef420ab 100644 --- a/src/blocks/index.ts +++ b/src/blocks/index.ts @@ -44,3 +44,6 @@ export { PricingBlock } from './PricingBlock' export { TabsBlock } from './TabsBlock' export { AccordionBlock } from './AccordionBlock' export { ComparisonBlock } from './ComparisonBlock' + +// Tenant-specific Blocks +export { BeforeAfterBlock } from './BeforeAfterBlock' diff --git a/src/collections/Bookings.ts b/src/collections/Bookings.ts new file mode 100644 index 0000000..daf978b --- /dev/null +++ b/src/collections/Bookings.ts @@ -0,0 +1,440 @@ +import type { CollectionConfig } from 'payload' + +/** + * Bookings Collection + * + * Terminbuchungen für Fotoshootings (porwoll.de) + * Ermöglicht Kunden die Online-Buchung von Terminen. + */ +export const Bookings: CollectionConfig = { + slug: 'bookings', + labels: { + singular: 'Buchung', + plural: 'Buchungen', + }, + admin: { + group: 'Buchungen', + useAsTitle: 'customerName', + defaultColumns: ['customerName', 'service', 'date', 'status', 'createdAt'], + description: 'Terminbuchungen für Fotoshootings', + }, + access: { + // Öffentlich erstellen (für Buchungsformular) + create: () => true, + // Nur authentifizierte Benutzer können lesen + read: ({ req: { user } }) => { + if (!user) return false + if (user.isSuperAdmin) return true + return { + tenant: { + equals: user.tenants?.[0]?.tenant, + }, + } + }, + update: ({ req: { user } }) => { + if (!user) return false + if (user.isSuperAdmin) return true + return { + tenant: { + equals: user.tenants?.[0]?.tenant, + }, + } + }, + delete: ({ req: { user } }) => { + if (!user) return false + if (user.isSuperAdmin) return true + return { + tenant: { + equals: user.tenants?.[0]?.tenant, + }, + } + }, + }, + fields: [ + // Kundendaten + { + type: 'row', + fields: [ + { + name: 'customerName', + type: 'text', + required: true, + label: 'Name', + admin: { + width: '50%', + }, + }, + { + name: 'customerEmail', + type: 'email', + required: true, + label: 'E-Mail', + admin: { + width: '50%', + }, + }, + ], + }, + { + type: 'row', + fields: [ + { + name: 'customerPhone', + type: 'text', + label: 'Telefon', + admin: { + width: '50%', + }, + }, + { + name: 'customerCompany', + type: 'text', + label: 'Firma', + admin: { + width: '50%', + }, + }, + ], + }, + // Service-Auswahl + { + name: 'serviceType', + type: 'select', + required: true, + label: 'Service-Typ', + options: [ + { label: 'Hochzeitsfotografie', value: 'wedding' }, + { label: 'Portraitfotografie', value: 'portrait' }, + { label: 'Business-Fotografie', value: 'business' }, + { label: 'Event-Fotografie', value: 'event' }, + { label: 'Produktfotografie', value: 'product' }, + { label: 'Familienfotografie', value: 'family' }, + { label: 'Newborn/Baby', value: 'newborn' }, + { label: 'Schwangerschaft', value: 'maternity' }, + { label: 'Immobilienfotografie', value: 'realestate' }, + { label: 'Sonstiges', value: 'other' }, + ], + }, + { + name: 'service', + type: 'relationship', + relationTo: 'services', + label: 'Verknüpfter Service', + admin: { + description: 'Optional: Verknüpfung mit einem definierten Service', + }, + }, + // Termin + { + type: 'row', + fields: [ + { + name: 'date', + type: 'date', + required: true, + label: 'Datum', + admin: { + width: '50%', + date: { + pickerAppearance: 'dayOnly', + displayFormat: 'dd.MM.yyyy', + }, + }, + }, + { + name: 'time', + type: 'text', + label: 'Uhrzeit', + admin: { + width: '50%', + description: 'z.B. 14:00 Uhr', + }, + }, + ], + }, + { + name: 'duration', + type: 'select', + label: 'Geschätzte Dauer', + options: [ + { label: '30 Minuten', value: '30' }, + { label: '1 Stunde', value: '60' }, + { label: '2 Stunden', value: '120' }, + { label: '3 Stunden', value: '180' }, + { label: 'Halber Tag (4h)', value: '240' }, + { label: 'Ganzer Tag (8h)', value: '480' }, + { label: 'Nach Absprache', value: 'custom' }, + ], + }, + // Location + { + name: 'locationType', + type: 'select', + label: 'Location-Typ', + options: [ + { label: 'Studio', value: 'studio' }, + { label: 'Outdoor', value: 'outdoor' }, + { label: 'Beim Kunden', value: 'customer' }, + { label: 'Event-Location', value: 'event' }, + { label: 'Nach Absprache', value: 'tbd' }, + ], + }, + { + name: 'locationAddress', + type: 'textarea', + label: 'Adresse / Location-Details', + admin: { + condition: (data, siblingData) => + siblingData?.locationType && siblingData?.locationType !== 'studio', + }, + }, + // Zusätzliche Informationen + { + name: 'participants', + type: 'number', + label: 'Anzahl Personen', + min: 1, + admin: { + description: 'Wie viele Personen sollen fotografiert werden?', + }, + }, + { + name: 'message', + type: 'textarea', + label: 'Nachricht / Wünsche', + admin: { + description: 'Besondere Wünsche, Ideen oder Anmerkungen', + }, + }, + { + name: 'referenceImages', + type: 'array', + label: 'Referenzbilder', + admin: { + description: 'Beispielbilder für gewünschten Stil', + }, + fields: [ + { + name: 'image', + type: 'upload', + relationTo: 'media', + label: 'Bild', + }, + { + name: 'note', + type: 'text', + label: 'Anmerkung', + }, + ], + }, + // Status & Workflow + { + name: 'status', + type: 'select', + required: true, + defaultValue: 'pending', + label: 'Status', + options: [ + { label: 'Anfrage eingegangen', value: 'pending' }, + { label: 'In Prüfung', value: 'review' }, + { label: 'Bestätigt', value: 'confirmed' }, + { label: 'Anzahlung erhalten', value: 'deposit' }, + { label: 'Durchgeführt', value: 'completed' }, + { label: 'Storniert', value: 'cancelled' }, + { label: 'No-Show', value: 'noshow' }, + ], + admin: { + position: 'sidebar', + }, + }, + { + name: 'priority', + type: 'select', + defaultValue: 'normal', + label: 'Priorität', + options: [ + { label: 'Hoch', value: 'high' }, + { label: 'Normal', value: 'normal' }, + { label: 'Niedrig', value: 'low' }, + ], + admin: { + position: 'sidebar', + }, + }, + // Preisgestaltung + { + name: 'pricing', + type: 'group', + label: 'Preisgestaltung', + admin: { + condition: (data) => data?.status && data?.status !== 'pending', + }, + fields: [ + { + name: 'estimatedPrice', + type: 'number', + label: 'Geschätzter Preis', + admin: { + description: 'In Euro', + }, + }, + { + name: 'finalPrice', + type: 'number', + label: 'Endpreis', + }, + { + name: 'depositAmount', + type: 'number', + label: 'Anzahlung', + }, + { + name: 'depositPaid', + type: 'checkbox', + label: 'Anzahlung bezahlt', + defaultValue: false, + }, + { + name: 'fullyPaid', + type: 'checkbox', + label: 'Vollständig bezahlt', + defaultValue: false, + }, + ], + }, + // Interne Notizen + { + name: 'internalNotes', + type: 'array', + label: 'Interne Notizen', + admin: { + description: 'Nur für interne Verwendung', + }, + fields: [ + { + name: 'note', + type: 'textarea', + required: true, + label: 'Notiz', + }, + { + name: 'author', + type: 'relationship', + relationTo: 'users', + label: 'Autor', + admin: { + readOnly: true, + }, + }, + { + name: 'createdAt', + type: 'date', + label: 'Erstellt am', + admin: { + readOnly: true, + date: { + pickerAppearance: 'dayAndTime', + }, + }, + }, + ], + }, + // Kontakthistorie + { + name: 'contactHistory', + type: 'array', + label: 'Kontakthistorie', + fields: [ + { + name: 'type', + type: 'select', + required: true, + label: 'Art', + options: [ + { label: 'E-Mail gesendet', value: 'email_sent' }, + { label: 'E-Mail erhalten', value: 'email_received' }, + { label: 'Anruf', value: 'call' }, + { label: 'WhatsApp', value: 'whatsapp' }, + { label: 'Persönlich', value: 'inperson' }, + ], + }, + { + name: 'summary', + type: 'textarea', + required: true, + label: 'Zusammenfassung', + }, + { + name: 'date', + type: 'date', + label: 'Datum', + admin: { + date: { + pickerAppearance: 'dayAndTime', + }, + }, + }, + ], + }, + // Zugewiesener Fotograf + { + name: 'assignedTo', + type: 'relationship', + relationTo: 'users', + label: 'Zugewiesen an', + admin: { + position: 'sidebar', + }, + }, + // Quelle der Buchung + { + name: 'source', + type: 'select', + label: 'Buchungsquelle', + options: [ + { label: 'Website', value: 'website' }, + { label: 'Telefon', value: 'phone' }, + { label: 'E-Mail', value: 'email' }, + { label: 'Instagram', value: 'instagram' }, + { label: 'Facebook', value: 'facebook' }, + { label: 'Empfehlung', value: 'referral' }, + { label: 'Wiederkehrend', value: 'returning' }, + { label: 'Sonstiges', value: 'other' }, + ], + admin: { + position: 'sidebar', + }, + }, + // Datenschutz + { + name: 'gdprConsent', + type: 'checkbox', + label: 'DSGVO-Einwilligung', + defaultValue: false, + admin: { + position: 'sidebar', + description: 'Kunde hat Datenschutzerklärung akzeptiert', + }, + }, + ], + timestamps: true, + hooks: { + beforeChange: [ + ({ data, req, operation }) => { + // Auto-set author for new notes + if (data?.internalNotes && req.user) { + data.internalNotes = data.internalNotes.map((note: Record) => { + if (!note.author) { + note.author = req.user?.id + } + if (!note.createdAt) { + note.createdAt = new Date().toISOString() + } + return note + }) + } + return data + }, + ], + }, +} diff --git a/src/collections/Certifications.ts b/src/collections/Certifications.ts new file mode 100644 index 0000000..3e2e840 --- /dev/null +++ b/src/collections/Certifications.ts @@ -0,0 +1,451 @@ +import type { CollectionConfig } from 'payload' + +/** + * Certifications Collection + * + * Zertifizierungen, Akkreditierungen und Qualitätssiegel (C2S) + * Für Pflegeeinrichtungen und Gesundheitsdienstleister. + */ +export const Certifications: CollectionConfig = { + slug: 'certifications', + labels: { + singular: 'Zertifizierung', + plural: 'Zertifizierungen', + }, + admin: { + group: 'Qualität', + useAsTitle: 'name', + defaultColumns: ['name', 'issuer', 'type', 'validUntil', 'status'], + description: 'Zertifizierungen, Akkreditierungen und Qualitätssiegel', + }, + access: { + read: () => true, + create: ({ req: { user } }) => Boolean(user), + update: ({ req: { user } }) => Boolean(user), + delete: ({ req: { user } }) => Boolean(user?.isSuperAdmin), + }, + fields: [ + // Grundinformationen + { + name: 'name', + type: 'text', + required: true, + label: 'Name der Zertifizierung', + localized: true, + }, + { + name: 'slug', + type: 'text', + required: true, + unique: true, + label: 'Slug', + admin: { + description: 'URL-freundlicher Name', + }, + }, + { + name: 'description', + type: 'richText', + label: 'Beschreibung', + localized: true, + }, + { + name: 'shortDescription', + type: 'textarea', + label: 'Kurzbeschreibung', + localized: true, + admin: { + description: 'Für Übersichten und Meta-Beschreibungen', + }, + }, + // Zertifizierungs-Typ + { + name: 'type', + type: 'select', + required: true, + label: 'Typ', + options: [ + { label: 'ISO-Zertifizierung', value: 'iso' }, + { label: 'DIN-Norm', value: 'din' }, + { label: 'Akkreditierung', value: 'accreditation' }, + { label: 'Qualitätssiegel', value: 'seal' }, + { label: 'Mitgliedschaft', value: 'membership' }, + { label: 'Auszeichnung', value: 'award' }, + { label: 'Lizenz', value: 'license' }, + { label: 'Genehmigung', value: 'approval' }, + { label: 'Sonstiges', value: 'other' }, + ], + }, + // Branchen-Kategorien (speziell für Pflege/Gesundheit) + { + name: 'category', + type: 'select', + label: 'Branchenkategorie', + options: [ + { label: 'Qualitätsmanagement', value: 'quality' }, + { label: 'Pflege', value: 'care' }, + { label: 'Medizin', value: 'medical' }, + { label: 'Hygiene', value: 'hygiene' }, + { label: 'Arbeitsschutz', value: 'safety' }, + { label: 'Datenschutz', value: 'privacy' }, + { label: 'Umwelt', value: 'environment' }, + { label: 'Personal', value: 'hr' }, + { label: 'IT-Sicherheit', value: 'it-security' }, + { label: 'Barrierefreiheit', value: 'accessibility' }, + { label: 'Sonstiges', value: 'other' }, + ], + }, + // Aussteller + { + name: 'issuer', + type: 'group', + label: 'Ausstellende Organisation', + fields: [ + { + name: 'name', + type: 'text', + required: true, + label: 'Name', + }, + { + name: 'logo', + type: 'upload', + relationTo: 'media', + label: 'Logo', + }, + { + name: 'website', + type: 'text', + label: 'Website', + }, + { + name: 'country', + type: 'select', + label: 'Land', + options: [ + { label: 'Deutschland', value: 'DE' }, + { label: 'Österreich', value: 'AT' }, + { label: 'Schweiz', value: 'CH' }, + { label: 'EU', value: 'EU' }, + { label: 'International', value: 'INT' }, + ], + }, + ], + }, + // Zertifikat-Details + { + name: 'certNumber', + type: 'text', + label: 'Zertifikatsnummer', + }, + { + type: 'row', + fields: [ + { + name: 'issuedDate', + type: 'date', + label: 'Ausstellungsdatum', + admin: { + width: '50%', + date: { + pickerAppearance: 'dayOnly', + displayFormat: 'dd.MM.yyyy', + }, + }, + }, + { + name: 'validUntil', + type: 'date', + label: 'Gültig bis', + admin: { + width: '50%', + date: { + pickerAppearance: 'dayOnly', + displayFormat: 'dd.MM.yyyy', + }, + }, + }, + ], + }, + { + name: 'renewalCycle', + type: 'select', + label: 'Erneuerungszyklus', + options: [ + { label: 'Jährlich', value: 'yearly' }, + { label: 'Alle 2 Jahre', value: '2years' }, + { label: 'Alle 3 Jahre', value: '3years' }, + { label: 'Alle 5 Jahre', value: '5years' }, + { label: 'Unbefristet', value: 'unlimited' }, + ], + }, + // Medien + { + name: 'logo', + type: 'upload', + relationTo: 'media', + label: 'Zertifikats-Logo/Siegel', + }, + { + name: 'certificate', + type: 'upload', + relationTo: 'media', + label: 'Zertifikat (PDF)', + admin: { + description: 'Das offizielle Zertifikatsdokument', + }, + }, + { + name: 'gallery', + type: 'array', + label: 'Weitere Dokumente', + fields: [ + { + name: 'document', + type: 'upload', + relationTo: 'media', + label: 'Dokument', + }, + { + name: 'title', + type: 'text', + label: 'Titel', + }, + ], + }, + // Geltungsbereich + { + name: 'scope', + type: 'group', + label: 'Geltungsbereich', + fields: [ + { + name: 'description', + type: 'textarea', + label: 'Beschreibung des Geltungsbereichs', + localized: true, + }, + { + name: 'locations', + type: 'relationship', + relationTo: 'locations', + hasMany: true, + label: 'Standorte', + admin: { + description: 'Für welche Standorte gilt die Zertifizierung?', + }, + }, + { + name: 'services', + type: 'relationship', + relationTo: 'services', + hasMany: true, + label: 'Leistungen', + admin: { + description: 'Für welche Leistungen gilt die Zertifizierung?', + }, + }, + ], + }, + // Anforderungen & Standards + { + name: 'requirements', + type: 'array', + label: 'Erfüllte Anforderungen', + fields: [ + { + name: 'requirement', + type: 'text', + required: true, + label: 'Anforderung', + localized: true, + }, + { + name: 'description', + type: 'textarea', + label: 'Erläuterung', + localized: true, + }, + ], + }, + // Vorteile für Kunden + { + name: 'benefits', + type: 'array', + label: 'Vorteile / Was bedeutet das für Sie?', + fields: [ + { + name: 'title', + type: 'text', + required: true, + label: 'Titel', + localized: true, + }, + { + name: 'description', + type: 'textarea', + label: 'Beschreibung', + localized: true, + }, + { + name: 'icon', + type: 'select', + label: 'Icon', + options: [ + { label: 'Häkchen', value: 'check' }, + { label: 'Stern', value: 'star' }, + { label: 'Schild', value: 'shield' }, + { label: 'Herz', value: 'heart' }, + { label: 'Schloss', value: 'lock' }, + { label: 'Lupe', value: 'search' }, + { label: 'Uhr', value: 'clock' }, + { label: 'Dokument', value: 'document' }, + ], + }, + ], + }, + // Audit-Informationen + { + name: 'audits', + type: 'array', + label: 'Audits / Prüfungen', + admin: { + description: 'Historie der durchgeführten Audits', + }, + fields: [ + { + name: 'date', + type: 'date', + required: true, + label: 'Datum', + admin: { + date: { + pickerAppearance: 'dayOnly', + displayFormat: 'dd.MM.yyyy', + }, + }, + }, + { + name: 'type', + type: 'select', + label: 'Art', + options: [ + { label: 'Erst-Zertifizierung', value: 'initial' }, + { label: 'Überwachungsaudit', value: 'surveillance' }, + { label: 'Re-Zertifizierung', value: 'recertification' }, + { label: 'Sonderaudit', value: 'special' }, + ], + }, + { + name: 'result', + type: 'select', + label: 'Ergebnis', + options: [ + { label: 'Bestanden', value: 'passed' }, + { label: 'Mit Auflagen bestanden', value: 'conditional' }, + { label: 'Nicht bestanden', value: 'failed' }, + ], + }, + { + name: 'notes', + type: 'textarea', + label: 'Anmerkungen', + }, + ], + }, + // Status + { + name: 'status', + type: 'select', + required: true, + defaultValue: 'active', + label: 'Status', + options: [ + { label: 'Aktiv', value: 'active' }, + { label: 'In Bearbeitung', value: 'pending' }, + { label: 'Erneuerung fällig', value: 'renewal' }, + { label: 'Ausgesetzt', value: 'suspended' }, + { label: 'Abgelaufen', value: 'expired' }, + { label: 'Zurückgezogen', value: 'withdrawn' }, + ], + admin: { + position: 'sidebar', + }, + }, + // Sichtbarkeit + { + name: 'visibility', + type: 'select', + defaultValue: 'public', + label: 'Sichtbarkeit', + options: [ + { label: 'Öffentlich', value: 'public' }, + { label: 'Nur auf Anfrage', value: 'request' }, + { label: 'Intern', value: 'internal' }, + ], + admin: { + position: 'sidebar', + }, + }, + // Priorität für Anzeige + { + name: 'priority', + type: 'number', + defaultValue: 0, + label: 'Priorität', + admin: { + position: 'sidebar', + description: 'Höhere Zahl = höhere Priorität in der Anzeige', + }, + }, + // Auf Startseite anzeigen + { + name: 'showOnHomepage', + type: 'checkbox', + defaultValue: false, + label: 'Auf Startseite anzeigen', + admin: { + position: 'sidebar', + }, + }, + // SEO + { + name: 'seo', + type: 'group', + label: 'SEO', + fields: [ + { + name: 'metaTitle', + type: 'text', + label: 'Meta-Titel', + localized: true, + }, + { + name: 'metaDescription', + type: 'textarea', + label: 'Meta-Beschreibung', + localized: true, + }, + ], + }, + ], + timestamps: true, + hooks: { + beforeChange: [ + ({ data }) => { + // Auto-generate slug if not provided + if (data && !data.slug && data.name) { + data.slug = data.name + .toLowerCase() + .replace(/[äöüß]/g, (char: string) => { + const map: Record = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' } + return map[char] || char + }) + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + } + return data + }, + ], + }, +} diff --git a/src/collections/Pages.ts b/src/collections/Pages.ts index adb9ab9..4944448 100644 --- a/src/collections/Pages.ts +++ b/src/collections/Pages.ts @@ -41,6 +41,8 @@ import { TabsBlock, AccordionBlock, ComparisonBlock, + // Tenant-specific Blocks + BeforeAfterBlock, } from '../blocks' import { pagesAccess } from '../lib/access' @@ -136,6 +138,8 @@ export const Pages: CollectionConfig = { TabsBlock, AccordionBlock, ComparisonBlock, + // Tenant-specific Blocks + BeforeAfterBlock, ], }, { diff --git a/src/collections/Projects.ts b/src/collections/Projects.ts new file mode 100644 index 0000000..5439d8a --- /dev/null +++ b/src/collections/Projects.ts @@ -0,0 +1,626 @@ +import type { CollectionConfig } from 'payload' + +/** + * Projects Collection + * + * Projekte für Spieleentwicklung und kreative Arbeiten (gunshin) + * Portfolio von Spielen, Mods, Tools und anderen Projekten. + */ +export const Projects: CollectionConfig = { + slug: 'projects', + labels: { + singular: 'Projekt', + plural: 'Projekte', + }, + admin: { + group: 'Inhalte', + useAsTitle: 'title', + defaultColumns: ['title', 'type', 'status', 'platform', 'releaseDate'], + description: 'Projekte, Spiele und kreative Arbeiten', + }, + access: { + read: () => true, + create: ({ req: { user } }) => Boolean(user), + update: ({ req: { user } }) => Boolean(user), + delete: ({ req: { user } }) => Boolean(user?.isSuperAdmin), + }, + fields: [ + // Grundinformationen + { + name: 'title', + type: 'text', + required: true, + label: 'Projekttitel', + localized: true, + }, + { + name: 'slug', + type: 'text', + required: true, + unique: true, + label: 'Slug', + }, + { + name: 'tagline', + type: 'text', + label: 'Tagline', + localized: true, + admin: { + description: 'Kurze, prägnante Beschreibung (1 Zeile)', + }, + }, + { + name: 'description', + type: 'richText', + label: 'Beschreibung', + localized: true, + }, + { + name: 'shortDescription', + type: 'textarea', + label: 'Kurzbeschreibung', + localized: true, + admin: { + description: 'Für Übersichten und Social Media', + }, + }, + // Projekt-Typ + { + name: 'type', + type: 'select', + required: true, + label: 'Projekt-Typ', + options: [ + { label: 'Spiel (Vollversion)', value: 'game' }, + { label: 'Demo', value: 'demo' }, + { label: 'Mod', value: 'mod' }, + { label: 'Tool / Utility', value: 'tool' }, + { label: 'Asset Pack', value: 'assets' }, + { label: 'Prototyp', value: 'prototype' }, + { label: 'Game Jam', value: 'gamejam' }, + { label: 'Tutorial / Kurs', value: 'tutorial' }, + { label: 'Open Source', value: 'opensource' }, + { label: 'Sonstiges', value: 'other' }, + ], + }, + // Genre + { + name: 'genres', + type: 'select', + hasMany: true, + label: 'Genres', + options: [ + { label: 'Action', value: 'action' }, + { label: 'Adventure', value: 'adventure' }, + { label: 'RPG', value: 'rpg' }, + { label: 'Strategie', value: 'strategy' }, + { label: 'Simulation', value: 'simulation' }, + { label: 'Puzzle', value: 'puzzle' }, + { label: 'Horror', value: 'horror' }, + { label: 'Shooter', value: 'shooter' }, + { label: 'Platformer', value: 'platformer' }, + { label: 'Racing', value: 'racing' }, + { label: 'Sports', value: 'sports' }, + { label: 'Fighting', value: 'fighting' }, + { label: 'Music/Rhythm', value: 'music' }, + { label: 'Visual Novel', value: 'visualnovel' }, + { label: 'Survival', value: 'survival' }, + { label: 'Sandbox', value: 'sandbox' }, + { label: 'Tower Defense', value: 'towerdefense' }, + { label: 'Roguelike', value: 'roguelike' }, + { label: 'Indie', value: 'indie' }, + ], + }, + // Plattformen + { + name: 'platforms', + type: 'select', + hasMany: true, + label: 'Plattformen', + options: [ + { label: 'Windows', value: 'windows' }, + { label: 'macOS', value: 'macos' }, + { label: 'Linux', value: 'linux' }, + { label: 'Web/Browser', value: 'web' }, + { label: 'iOS', value: 'ios' }, + { label: 'Android', value: 'android' }, + { label: 'PlayStation', value: 'playstation' }, + { label: 'Xbox', value: 'xbox' }, + { label: 'Nintendo Switch', value: 'switch' }, + { label: 'Steam Deck', value: 'steamdeck' }, + { label: 'VR', value: 'vr' }, + ], + }, + // Medien + { + name: 'featuredImage', + type: 'upload', + relationTo: 'media', + required: true, + label: 'Hauptbild', + }, + { + name: 'logo', + type: 'upload', + relationTo: 'media', + label: 'Projekt-Logo', + }, + { + name: 'screenshots', + type: 'array', + label: 'Screenshots', + fields: [ + { + name: 'image', + type: 'upload', + relationTo: 'media', + required: true, + label: 'Screenshot', + }, + { + name: 'caption', + type: 'text', + label: 'Bildunterschrift', + localized: true, + }, + ], + }, + { + name: 'videos', + type: 'array', + label: 'Videos', + fields: [ + { + name: 'type', + type: 'select', + required: true, + label: 'Typ', + options: [ + { label: 'Trailer', value: 'trailer' }, + { label: 'Gameplay', value: 'gameplay' }, + { label: 'Devlog', value: 'devlog' }, + { label: 'Tutorial', value: 'tutorial' }, + { label: 'Sonstiges', value: 'other' }, + ], + }, + { + name: 'title', + type: 'text', + label: 'Titel', + localized: true, + }, + { + name: 'url', + type: 'text', + required: true, + label: 'Video-URL', + admin: { + description: 'YouTube, Vimeo oder direkter Link', + }, + }, + { + name: 'thumbnail', + type: 'upload', + relationTo: 'media', + label: 'Thumbnail', + }, + ], + }, + // Technische Details + { + name: 'techStack', + type: 'group', + label: 'Technologie', + fields: [ + { + name: 'engine', + type: 'select', + label: 'Engine', + options: [ + { label: 'Unity', value: 'unity' }, + { label: 'Unreal Engine', value: 'unreal' }, + { label: 'Godot', value: 'godot' }, + { label: 'GameMaker', value: 'gamemaker' }, + { label: 'RPG Maker', value: 'rpgmaker' }, + { label: 'Construct', value: 'construct' }, + { label: 'Custom Engine', value: 'custom' }, + { label: 'Ren\'Py', value: 'renpy' }, + { label: 'Phaser', value: 'phaser' }, + { label: 'Sonstiges', value: 'other' }, + ], + }, + { + name: 'languages', + type: 'select', + hasMany: true, + label: 'Programmiersprachen', + options: [ + { label: 'C#', value: 'csharp' }, + { label: 'C++', value: 'cpp' }, + { label: 'GDScript', value: 'gdscript' }, + { label: 'JavaScript', value: 'javascript' }, + { label: 'TypeScript', value: 'typescript' }, + { label: 'Python', value: 'python' }, + { label: 'Lua', value: 'lua' }, + { label: 'Rust', value: 'rust' }, + { label: 'Blueprint', value: 'blueprint' }, + ], + }, + { + name: 'tools', + type: 'text', + label: 'Weitere Tools', + admin: { + description: 'z.B. Blender, Aseprite, FMOD', + }, + }, + ], + }, + // Systemanforderungen + { + name: 'requirements', + type: 'group', + label: 'Systemanforderungen', + admin: { + condition: (data) => + data?.type === 'game' || data?.type === 'demo' || data?.type === 'tool', + }, + fields: [ + { + name: 'minimum', + type: 'group', + label: 'Minimum', + fields: [ + { name: 'os', type: 'text', label: 'Betriebssystem' }, + { name: 'cpu', type: 'text', label: 'Prozessor' }, + { name: 'ram', type: 'text', label: 'RAM' }, + { name: 'gpu', type: 'text', label: 'Grafikkarte' }, + { name: 'storage', type: 'text', label: 'Speicherplatz' }, + ], + }, + { + name: 'recommended', + type: 'group', + label: 'Empfohlen', + fields: [ + { name: 'os', type: 'text', label: 'Betriebssystem' }, + { name: 'cpu', type: 'text', label: 'Prozessor' }, + { name: 'ram', type: 'text', label: 'RAM' }, + { name: 'gpu', type: 'text', label: 'Grafikkarte' }, + { name: 'storage', type: 'text', label: 'Speicherplatz' }, + ], + }, + ], + }, + // Veröffentlichung & Links + { + name: 'releaseDate', + type: 'date', + label: 'Veröffentlichungsdatum', + admin: { + date: { + pickerAppearance: 'dayOnly', + displayFormat: 'dd.MM.yyyy', + }, + }, + }, + { + name: 'links', + type: 'group', + label: 'Links', + fields: [ + { + name: 'website', + type: 'text', + label: 'Projekt-Website', + }, + { + name: 'steam', + type: 'text', + label: 'Steam', + }, + { + name: 'itchio', + type: 'text', + label: 'itch.io', + }, + { + name: 'epicGames', + type: 'text', + label: 'Epic Games Store', + }, + { + name: 'gog', + type: 'text', + label: 'GOG', + }, + { + name: 'playStore', + type: 'text', + label: 'Google Play Store', + }, + { + name: 'appStore', + type: 'text', + label: 'App Store', + }, + { + name: 'github', + type: 'text', + label: 'GitHub', + }, + { + name: 'discord', + type: 'text', + label: 'Discord', + }, + { + name: 'twitter', + type: 'text', + label: 'Twitter/X', + }, + ], + }, + // Downloads + { + name: 'downloads', + type: 'array', + label: 'Downloads', + fields: [ + { + name: 'title', + type: 'text', + required: true, + label: 'Titel', + admin: { + description: 'z.B. "Windows Build", "Demo v0.5"', + }, + }, + { + name: 'platform', + type: 'select', + label: 'Plattform', + options: [ + { label: 'Windows', value: 'windows' }, + { label: 'macOS', value: 'macos' }, + { label: 'Linux', value: 'linux' }, + { label: 'Universal', value: 'universal' }, + ], + }, + { + name: 'version', + type: 'text', + label: 'Version', + }, + { + name: 'file', + type: 'upload', + relationTo: 'media', + label: 'Datei', + }, + { + name: 'externalUrl', + type: 'text', + label: 'Externer Download-Link', + admin: { + condition: (data, siblingData) => !siblingData?.file, + }, + }, + { + name: 'size', + type: 'text', + label: 'Dateigröße', + }, + ], + }, + // Features + { + name: 'features', + type: 'array', + label: 'Features', + fields: [ + { + name: 'title', + type: 'text', + required: true, + label: 'Feature', + localized: true, + }, + { + name: 'description', + type: 'textarea', + label: 'Beschreibung', + localized: true, + }, + { + name: 'icon', + type: 'upload', + relationTo: 'media', + label: 'Icon', + }, + ], + }, + // Team / Credits + { + name: 'team', + type: 'array', + label: 'Team / Credits', + fields: [ + { + name: 'name', + type: 'text', + required: true, + label: 'Name', + }, + { + name: 'role', + type: 'text', + label: 'Rolle', + localized: true, + }, + { + name: 'link', + type: 'text', + label: 'Link (Portfolio/Social)', + }, + { + name: 'avatar', + type: 'upload', + relationTo: 'media', + label: 'Avatar', + }, + ], + }, + // Game Jam Info + { + name: 'gameJam', + type: 'group', + label: 'Game Jam Info', + admin: { + condition: (data) => data?.type === 'gamejam', + }, + fields: [ + { + name: 'jamName', + type: 'text', + label: 'Jam-Name', + }, + { + name: 'theme', + type: 'text', + label: 'Thema', + }, + { + name: 'duration', + type: 'text', + label: 'Dauer', + admin: { + description: 'z.B. "48 Stunden"', + }, + }, + { + name: 'ranking', + type: 'text', + label: 'Platzierung', + }, + { + name: 'jamLink', + type: 'text', + label: 'Link zum Jam', + }, + ], + }, + // Devlog Posts (Verknüpfung) + { + name: 'devlogs', + type: 'relationship', + relationTo: 'posts', + hasMany: true, + label: 'Devlog-Posts', + admin: { + description: 'Verknüpfte Blog-Posts über dieses Projekt', + }, + }, + // Status + { + name: 'status', + type: 'select', + required: true, + defaultValue: 'development', + label: 'Status', + options: [ + { label: 'In Entwicklung', value: 'development' }, + { label: 'Early Access', value: 'earlyaccess' }, + { label: 'Released', value: 'released' }, + { label: 'Pausiert', value: 'paused' }, + { label: 'Eingestellt', value: 'cancelled' }, + { label: 'Abgeschlossen', value: 'completed' }, + ], + admin: { + position: 'sidebar', + }, + }, + // Sichtbarkeit + { + name: 'visibility', + type: 'select', + defaultValue: 'public', + label: 'Sichtbarkeit', + options: [ + { label: 'Öffentlich', value: 'public' }, + { label: 'Entwurf', value: 'draft' }, + { label: 'Unlisted', value: 'unlisted' }, + { label: 'Privat', value: 'private' }, + ], + admin: { + position: 'sidebar', + }, + }, + // Featured + { + name: 'featured', + type: 'checkbox', + defaultValue: false, + label: 'Featured Projekt', + admin: { + position: 'sidebar', + }, + }, + // Sortierung + { + name: 'sortOrder', + type: 'number', + defaultValue: 0, + label: 'Sortierung', + admin: { + position: 'sidebar', + description: 'Höher = weiter oben', + }, + }, + // SEO + { + name: 'seo', + type: 'group', + label: 'SEO', + fields: [ + { + name: 'metaTitle', + type: 'text', + label: 'Meta-Titel', + localized: true, + }, + { + name: 'metaDescription', + type: 'textarea', + label: 'Meta-Beschreibung', + localized: true, + }, + { + name: 'ogImage', + type: 'upload', + relationTo: 'media', + label: 'Social Media Bild', + }, + ], + }, + ], + timestamps: true, + hooks: { + beforeChange: [ + ({ data }) => { + // Auto-generate slug + if (data && !data.slug && data.title) { + data.slug = data.title + .toLowerCase() + .replace(/[äöüß]/g, (char: string) => { + const map: Record = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' } + return map[char] || char + }) + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + } + return data + }, + ], + }, +} diff --git a/src/migrations/20251214_010000_tenant_specific_collections.ts b/src/migrations/20251214_010000_tenant_specific_collections.ts new file mode 100644 index 0000000..51e9e44 --- /dev/null +++ b/src/migrations/20251214_010000_tenant_specific_collections.ts @@ -0,0 +1,784 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Migration for tenant-specific collections: + * - Bookings (porwoll.de) + * - Certifications (C2S) + * - Projects (gunshin) + */ + +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + // ======================================== + // BOOKINGS COLLECTION (porwoll.de) + // ======================================== + + // Create enums for bookings + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_bookings_service_type" AS ENUM('wedding', 'portrait', 'business', 'event', 'product', 'family', 'newborn', 'maternity', 'realestate', 'other'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_bookings_duration" AS ENUM('30', '60', '120', '180', '240', '480', 'custom'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_bookings_location_type" AS ENUM('studio', 'outdoor', 'customer', 'event', 'tbd'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_bookings_status" AS ENUM('pending', 'review', 'confirmed', 'deposit', 'completed', 'cancelled', 'noshow'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_bookings_priority" AS ENUM('high', 'normal', 'low'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_bookings_source" AS ENUM('website', 'phone', 'email', 'instagram', 'facebook', 'referral', 'returning', 'other'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_bookings_contact_history_type" AS ENUM('email_sent', 'email_received', 'call', 'whatsapp', 'inperson'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + // Create bookings table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "bookings" ( + "id" serial PRIMARY KEY NOT NULL, + "tenant_id" integer REFERENCES "tenants"("id") ON DELETE SET NULL, + "customer_name" varchar NOT NULL, + "customer_email" varchar NOT NULL, + "customer_phone" varchar, + "customer_company" varchar, + "service_type" "enum_bookings_service_type" NOT NULL, + "service_id" integer REFERENCES "services"("id") ON DELETE SET NULL, + "date" timestamp(3) with time zone NOT NULL, + "time" varchar, + "duration" "enum_bookings_duration", + "location_type" "enum_bookings_location_type", + "location_address" varchar, + "participants" numeric, + "message" varchar, + "status" "enum_bookings_status" NOT NULL DEFAULT 'pending', + "priority" "enum_bookings_priority" DEFAULT 'normal', + "pricing_estimated_price" numeric, + "pricing_final_price" numeric, + "pricing_deposit_amount" numeric, + "pricing_deposit_paid" boolean DEFAULT false, + "pricing_fully_paid" boolean DEFAULT false, + "assigned_to_id" integer REFERENCES "users"("id") ON DELETE SET NULL, + "source" "enum_bookings_source", + "gdpr_consent" boolean DEFAULT false, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_tenant_idx" ON "bookings" USING btree ("tenant_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_service_idx" ON "bookings" USING btree ("service_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_assigned_to_idx" ON "bookings" USING btree ("assigned_to_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_updated_at_idx" ON "bookings" USING btree ("updated_at");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_created_at_idx" ON "bookings" USING btree ("created_at");`) + + // Create bookings_reference_images table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "bookings_reference_images" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "bookings"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL, + "image_id" integer REFERENCES "media"("id") ON DELETE SET NULL, + "note" varchar + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_reference_images_order_idx" ON "bookings_reference_images" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_reference_images_parent_id_idx" ON "bookings_reference_images" USING btree ("_parent_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_reference_images_image_idx" ON "bookings_reference_images" USING btree ("image_id");`) + + // Create bookings_internal_notes table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "bookings_internal_notes" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "bookings"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL, + "note" varchar NOT NULL, + "author_id" integer REFERENCES "users"("id") ON DELETE SET NULL, + "created_at" timestamp(3) with time zone + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_internal_notes_order_idx" ON "bookings_internal_notes" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_internal_notes_parent_id_idx" ON "bookings_internal_notes" USING btree ("_parent_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_internal_notes_author_idx" ON "bookings_internal_notes" USING btree ("author_id");`) + + // Create bookings_contact_history table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "bookings_contact_history" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "bookings"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL, + "type" "enum_bookings_contact_history_type" NOT NULL, + "summary" varchar NOT NULL, + "date" timestamp(3) with time zone + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_contact_history_order_idx" ON "bookings_contact_history" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "bookings_contact_history_parent_id_idx" ON "bookings_contact_history" USING btree ("_parent_id");`) + + // ======================================== + // CERTIFICATIONS COLLECTION (C2S) + // ======================================== + + // Create enums for certifications + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_certifications_type" AS ENUM('iso', 'din', 'accreditation', 'seal', 'membership', 'award', 'license', 'approval', 'other'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_certifications_category" AS ENUM('quality', 'care', 'medical', 'hygiene', 'safety', 'privacy', 'environment', 'hr', 'it-security', 'accessibility', 'other'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_certifications_issuer_country" AS ENUM('DE', 'AT', 'CH', 'EU', 'INT'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_certifications_renewal_cycle" AS ENUM('yearly', '2years', '3years', '5years', 'unlimited'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_certifications_status" AS ENUM('active', 'pending', 'renewal', 'suspended', 'expired', 'withdrawn'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_certifications_visibility" AS ENUM('public', 'request', 'internal'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_certifications_benefits_icon" AS ENUM('check', 'star', 'shield', 'heart', 'lock', 'search', 'clock', 'document'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_certifications_audits_type" AS ENUM('initial', 'surveillance', 'recertification', 'special'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_certifications_audits_result" AS ENUM('passed', 'conditional', 'failed'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + // Create certifications table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "certifications" ( + "id" serial PRIMARY KEY NOT NULL, + "tenant_id" integer REFERENCES "tenants"("id") ON DELETE SET NULL, + "slug" varchar NOT NULL, + "type" "enum_certifications_type" NOT NULL, + "category" "enum_certifications_category", + "issuer_name" varchar NOT NULL, + "issuer_logo_id" integer REFERENCES "media"("id") ON DELETE SET NULL, + "issuer_website" varchar, + "issuer_country" "enum_certifications_issuer_country", + "cert_number" varchar, + "issued_date" timestamp(3) with time zone, + "valid_until" timestamp(3) with time zone, + "renewal_cycle" "enum_certifications_renewal_cycle", + "logo_id" integer REFERENCES "media"("id") ON DELETE SET NULL, + "certificate_id" integer REFERENCES "media"("id") ON DELETE SET NULL, + "status" "enum_certifications_status" NOT NULL DEFAULT 'active', + "visibility" "enum_certifications_visibility" DEFAULT 'public', + "priority" numeric DEFAULT 0, + "show_on_homepage" boolean DEFAULT false, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_tenant_idx" ON "certifications" USING btree ("tenant_id");`) + await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "certifications_slug_idx" ON "certifications" USING btree ("slug");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_issuer_logo_idx" ON "certifications" USING btree ("issuer_logo_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_logo_idx" ON "certifications" USING btree ("logo_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_certificate_idx" ON "certifications" USING btree ("certificate_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_updated_at_idx" ON "certifications" USING btree ("updated_at");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_created_at_idx" ON "certifications" USING btree ("created_at");`) + + // Create certifications_locales table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "certifications_locales" ( + "name" varchar NOT NULL, + "description" jsonb, + "short_description" varchar, + "scope_description" varchar, + "seo_meta_title" varchar, + "seo_meta_description" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "certifications"("id") ON DELETE CASCADE + ); + `) + + await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "certifications_locales_locale_parent_id_unique" ON "certifications_locales" USING btree ("_locale", "_parent_id");`) + + // Create certifications_gallery table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "certifications_gallery" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "certifications"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL, + "document_id" integer REFERENCES "media"("id") ON DELETE SET NULL, + "title" varchar + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_gallery_order_idx" ON "certifications_gallery" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_gallery_parent_id_idx" ON "certifications_gallery" USING btree ("_parent_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_gallery_document_idx" ON "certifications_gallery" USING btree ("document_id");`) + + // Create certifications_requirements table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "certifications_requirements" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "certifications"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_requirements_order_idx" ON "certifications_requirements" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_requirements_parent_id_idx" ON "certifications_requirements" USING btree ("_parent_id");`) + + // Create certifications_requirements_locales table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "certifications_requirements_locales" ( + "requirement" varchar NOT NULL, + "description" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL REFERENCES "certifications_requirements"("id") ON DELETE CASCADE + ); + `) + + await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "certifications_requirements_locales_locale_parent_id_unique" ON "certifications_requirements_locales" USING btree ("_locale", "_parent_id");`) + + // Create certifications_benefits table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "certifications_benefits" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "certifications"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL, + "icon" "enum_certifications_benefits_icon" + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_benefits_order_idx" ON "certifications_benefits" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_benefits_parent_id_idx" ON "certifications_benefits" USING btree ("_parent_id");`) + + // Create certifications_benefits_locales table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "certifications_benefits_locales" ( + "title" varchar NOT NULL, + "description" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL REFERENCES "certifications_benefits"("id") ON DELETE CASCADE + ); + `) + + await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "certifications_benefits_locales_locale_parent_id_unique" ON "certifications_benefits_locales" USING btree ("_locale", "_parent_id");`) + + // Create certifications_audits table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "certifications_audits" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "certifications"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL, + "date" timestamp(3) with time zone NOT NULL, + "type" "enum_certifications_audits_type", + "result" "enum_certifications_audits_result", + "notes" varchar + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_audits_order_idx" ON "certifications_audits" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_audits_parent_id_idx" ON "certifications_audits" USING btree ("_parent_id");`) + + // Create certifications_rels table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "certifications_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" integer NOT NULL REFERENCES "certifications"("id") ON DELETE CASCADE, + "path" varchar NOT NULL, + "locations_id" integer REFERENCES "locations"("id") ON DELETE CASCADE, + "services_id" integer REFERENCES "services"("id") ON DELETE CASCADE + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_rels_order_idx" ON "certifications_rels" USING btree ("order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_rels_parent_idx" ON "certifications_rels" USING btree ("parent_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_rels_path_idx" ON "certifications_rels" USING btree ("path");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_rels_locations_id_idx" ON "certifications_rels" USING btree ("locations_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "certifications_rels_services_id_idx" ON "certifications_rels" USING btree ("services_id");`) + + // ======================================== + // PROJECTS COLLECTION (gunshin) + // ======================================== + + // Create enums for projects + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_projects_type" AS ENUM('game', 'demo', 'mod', 'tool', 'assets', 'prototype', 'gamejam', 'tutorial', 'opensource', 'other'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_projects_genres" AS ENUM('action', 'adventure', 'rpg', 'strategy', 'simulation', 'puzzle', 'horror', 'shooter', 'platformer', 'racing', 'sports', 'fighting', 'music', 'visualnovel', 'survival', 'sandbox', 'towerdefense', 'roguelike', 'indie'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_projects_platforms" AS ENUM('windows', 'macos', 'linux', 'web', 'ios', 'android', 'playstation', 'xbox', 'switch', 'steamdeck', 'vr'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_projects_tech_stack_engine" AS ENUM('unity', 'unreal', 'godot', 'gamemaker', 'rpgmaker', 'construct', 'custom', 'renpy', 'phaser', 'other'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_projects_tech_stack_languages" AS ENUM('csharp', 'cpp', 'gdscript', 'javascript', 'typescript', 'python', 'lua', 'rust', 'blueprint'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_projects_videos_type" AS ENUM('trailer', 'gameplay', 'devlog', 'tutorial', 'other'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_projects_downloads_platform" AS ENUM('windows', 'macos', 'linux', 'universal'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_projects_status" AS ENUM('development', 'earlyaccess', 'released', 'paused', 'cancelled', 'completed'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_projects_visibility" AS ENUM('public', 'draft', 'unlisted', 'private'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `) + + // Create projects table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects" ( + "id" serial PRIMARY KEY NOT NULL, + "tenant_id" integer REFERENCES "tenants"("id") ON DELETE SET NULL, + "slug" varchar NOT NULL, + "type" "enum_projects_type" NOT NULL, + "featured_image_id" integer NOT NULL REFERENCES "media"("id") ON DELETE SET NULL, + "logo_id" integer REFERENCES "media"("id") ON DELETE SET NULL, + "tech_stack_engine" "enum_projects_tech_stack_engine", + "tech_stack_tools" varchar, + "requirements_minimum_os" varchar, + "requirements_minimum_cpu" varchar, + "requirements_minimum_ram" varchar, + "requirements_minimum_gpu" varchar, + "requirements_minimum_storage" varchar, + "requirements_recommended_os" varchar, + "requirements_recommended_cpu" varchar, + "requirements_recommended_ram" varchar, + "requirements_recommended_gpu" varchar, + "requirements_recommended_storage" varchar, + "release_date" timestamp(3) with time zone, + "links_website" varchar, + "links_steam" varchar, + "links_itchio" varchar, + "links_epic_games" varchar, + "links_gog" varchar, + "links_play_store" varchar, + "links_app_store" varchar, + "links_github" varchar, + "links_discord" varchar, + "links_twitter" varchar, + "game_jam_jam_name" varchar, + "game_jam_theme" varchar, + "game_jam_duration" varchar, + "game_jam_ranking" varchar, + "game_jam_jam_link" varchar, + "status" "enum_projects_status" NOT NULL DEFAULT 'development', + "visibility" "enum_projects_visibility" DEFAULT 'public', + "featured" boolean DEFAULT false, + "sort_order" numeric DEFAULT 0, + "seo_og_image_id" integer REFERENCES "media"("id") ON DELETE SET NULL, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_tenant_idx" ON "projects" USING btree ("tenant_id");`) + await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "projects_slug_idx" ON "projects" USING btree ("slug");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_featured_image_idx" ON "projects" USING btree ("featured_image_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_logo_idx" ON "projects" USING btree ("logo_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_seo_og_image_idx" ON "projects" USING btree ("seo_og_image_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_updated_at_idx" ON "projects" USING btree ("updated_at");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_created_at_idx" ON "projects" USING btree ("created_at");`) + + // Create projects_locales table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_locales" ( + "title" varchar NOT NULL, + "tagline" varchar, + "description" jsonb, + "short_description" varchar, + "seo_meta_title" varchar, + "seo_meta_description" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE + ); + `) + + await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "projects_locales_locale_parent_id_unique" ON "projects_locales" USING btree ("_locale", "_parent_id");`) + + // Create projects_genres table (many-to-many via select) + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_genres" ( + "order" integer NOT NULL, + "parent_id" integer NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "value" "enum_projects_genres", + "id" serial PRIMARY KEY NOT NULL + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_genres_order_idx" ON "projects_genres" USING btree ("order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_genres_parent_idx" ON "projects_genres" USING btree ("parent_id");`) + + // Create projects_platforms table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_platforms" ( + "order" integer NOT NULL, + "parent_id" integer NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "value" "enum_projects_platforms", + "id" serial PRIMARY KEY NOT NULL + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_platforms_order_idx" ON "projects_platforms" USING btree ("order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_platforms_parent_idx" ON "projects_platforms" USING btree ("parent_id");`) + + // Create projects_tech_stack_languages table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_tech_stack_languages" ( + "order" integer NOT NULL, + "parent_id" integer NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "value" "enum_projects_tech_stack_languages", + "id" serial PRIMARY KEY NOT NULL + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_tech_stack_languages_order_idx" ON "projects_tech_stack_languages" USING btree ("order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_tech_stack_languages_parent_idx" ON "projects_tech_stack_languages" USING btree ("parent_id");`) + + // Create projects_screenshots table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_screenshots" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL, + "image_id" integer NOT NULL REFERENCES "media"("id") ON DELETE SET NULL + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_screenshots_order_idx" ON "projects_screenshots" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_screenshots_parent_id_idx" ON "projects_screenshots" USING btree ("_parent_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_screenshots_image_idx" ON "projects_screenshots" USING btree ("image_id");`) + + // Create projects_screenshots_locales table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_screenshots_locales" ( + "caption" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL REFERENCES "projects_screenshots"("id") ON DELETE CASCADE + ); + `) + + await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "projects_screenshots_locales_locale_parent_id_unique" ON "projects_screenshots_locales" USING btree ("_locale", "_parent_id");`) + + // Create projects_videos table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_videos" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL, + "type" "enum_projects_videos_type" NOT NULL, + "url" varchar NOT NULL, + "thumbnail_id" integer REFERENCES "media"("id") ON DELETE SET NULL + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_videos_order_idx" ON "projects_videos" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_videos_parent_id_idx" ON "projects_videos" USING btree ("_parent_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_videos_thumbnail_idx" ON "projects_videos" USING btree ("thumbnail_id");`) + + // Create projects_videos_locales table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_videos_locales" ( + "title" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL REFERENCES "projects_videos"("id") ON DELETE CASCADE + ); + `) + + await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "projects_videos_locales_locale_parent_id_unique" ON "projects_videos_locales" USING btree ("_locale", "_parent_id");`) + + // Create projects_downloads table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_downloads" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL, + "title" varchar NOT NULL, + "platform" "enum_projects_downloads_platform", + "version" varchar, + "file_id" integer REFERENCES "media"("id") ON DELETE SET NULL, + "external_url" varchar, + "size" varchar + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_downloads_order_idx" ON "projects_downloads" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_downloads_parent_id_idx" ON "projects_downloads" USING btree ("_parent_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_downloads_file_idx" ON "projects_downloads" USING btree ("file_id");`) + + // Create projects_features table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_features" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL, + "icon_id" integer REFERENCES "media"("id") ON DELETE SET NULL + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_features_order_idx" ON "projects_features" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_features_parent_id_idx" ON "projects_features" USING btree ("_parent_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_features_icon_idx" ON "projects_features" USING btree ("icon_id");`) + + // Create projects_features_locales table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_features_locales" ( + "title" varchar NOT NULL, + "description" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL REFERENCES "projects_features"("id") ON DELETE CASCADE + ); + `) + + await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "projects_features_locales_locale_parent_id_unique" ON "projects_features_locales" USING btree ("_locale", "_parent_id");`) + + // Create projects_team table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_team" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "id" varchar PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "link" varchar, + "avatar_id" integer REFERENCES "media"("id") ON DELETE SET NULL + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_team_order_idx" ON "projects_team" USING btree ("_order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_team_parent_id_idx" ON "projects_team" USING btree ("_parent_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_team_avatar_idx" ON "projects_team" USING btree ("avatar_id");`) + + // Create projects_team_locales table + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_team_locales" ( + "role" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" varchar NOT NULL REFERENCES "projects_team"("id") ON DELETE CASCADE + ); + `) + + await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "projects_team_locales_locale_parent_id_unique" ON "projects_team_locales" USING btree ("_locale", "_parent_id");`) + + // Create projects_rels table for devlogs relationship + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "projects_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" integer NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "path" varchar NOT NULL, + "posts_id" integer REFERENCES "posts"("id") ON DELETE CASCADE + ); + `) + + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_rels_order_idx" ON "projects_rels" USING btree ("order");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_rels_parent_idx" ON "projects_rels" USING btree ("parent_id");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_rels_path_idx" ON "projects_rels" USING btree ("path");`) + await db.execute(sql`CREATE INDEX IF NOT EXISTS "projects_rels_posts_id_idx" ON "projects_rels" USING btree ("posts_id");`) +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + // Drop projects tables + await db.execute(sql`DROP TABLE IF EXISTS "projects_rels" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_team_locales" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_team" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_features_locales" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_features" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_downloads" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_videos_locales" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_videos" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_screenshots_locales" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_screenshots" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_tech_stack_languages" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_platforms" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_genres" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects_locales" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "projects" CASCADE;`) + + // Drop certifications tables + await db.execute(sql`DROP TABLE IF EXISTS "certifications_rels" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "certifications_audits" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "certifications_benefits_locales" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "certifications_benefits" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "certifications_requirements_locales" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "certifications_requirements" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "certifications_gallery" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "certifications_locales" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "certifications" CASCADE;`) + + // Drop bookings tables + await db.execute(sql`DROP TABLE IF EXISTS "bookings_contact_history" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "bookings_internal_notes" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "bookings_reference_images" CASCADE;`) + await db.execute(sql`DROP TABLE IF EXISTS "bookings" CASCADE;`) + + // Drop projects enums + await db.execute(sql`DROP TYPE IF EXISTS "enum_projects_visibility" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_projects_status" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_projects_downloads_platform" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_projects_videos_type" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_projects_tech_stack_languages" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_projects_tech_stack_engine" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_projects_platforms" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_projects_genres" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_projects_type" CASCADE;`) + + // Drop certifications enums + await db.execute(sql`DROP TYPE IF EXISTS "enum_certifications_audits_result" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_certifications_audits_type" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_certifications_benefits_icon" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_certifications_visibility" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_certifications_status" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_certifications_renewal_cycle" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_certifications_issuer_country" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_certifications_category" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_certifications_type" CASCADE;`) + + // Drop bookings enums + await db.execute(sql`DROP TYPE IF EXISTS "enum_bookings_contact_history_type" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_bookings_source" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_bookings_priority" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_bookings_status" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_bookings_location_type" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_bookings_duration" CASCADE;`) + await db.execute(sql`DROP TYPE IF EXISTS "enum_bookings_service_type" CASCADE;`) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index e547ec9..0524f14 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -17,6 +17,7 @@ import * as migration_20251213_210000_image_slider_block from './20251213_210000 import * as migration_20251213_220000_blogging_collections from './20251213_220000_blogging_collections'; import * as migration_20251213_230000_team_extensions from './20251213_230000_team_extensions'; import * as migration_20251214_000000_add_priority_collections from './20251214_000000_add_priority_collections'; +import * as migration_20251214_010000_tenant_specific_collections from './20251214_010000_tenant_specific_collections'; export const migrations = [ { @@ -114,4 +115,9 @@ export const migrations = [ down: migration_20251214_000000_add_priority_collections.down, name: '20251214_000000_add_priority_collections', }, + { + up: migration_20251214_010000_tenant_specific_collections.up, + down: migration_20251214_010000_tenant_specific_collections.down, + name: '20251214_010000_tenant_specific_collections', + }, ]; diff --git a/src/payload.config.ts b/src/payload.config.ts index fa47863..9662d14 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -55,6 +55,11 @@ import { Jobs } from './collections/Jobs' import { Downloads } from './collections/Downloads' import { Events } from './collections/Events' +// Tenant-specific Collections +import { Bookings } from './collections/Bookings' +import { Certifications } from './collections/Certifications' +import { Projects } from './collections/Projects' + // Consent Management Collections import { CookieConfigurations } from './collections/CookieConfigurations' import { CookieInventory } from './collections/CookieInventory' @@ -181,6 +186,10 @@ export default buildConfig({ Jobs, Downloads, Events, + // Tenant-specific Collections + Bookings, + Certifications, + Projects, // Consent Management CookieConfigurations, CookieInventory, @@ -240,6 +249,10 @@ export default buildConfig({ jobs: {}, downloads: {}, events: {}, + // Tenant-specific Collections + bookings: {}, + certifications: {}, + projects: {}, // Consent Management Collections - customTenantField: true weil sie bereits ein tenant-Feld haben 'cookie-configurations': { customTenantField: true }, 'cookie-inventory': { customTenantField: true },