feat: add tenant-specific collections and BeforeAfterBlock

- Add Bookings Collection for porwoll.de (photography booking system)
- Add Certifications Collection for C2S (healthcare certifications)
- Add Projects Collection for gunshin.de (game development portfolio)
- Add BeforeAfterBlock for before/after image comparisons
- Add migration for 28 new database tables
- Update documentation and clean up TODO.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-14 01:40:17 +00:00
parent 9e5c741941
commit 05fba7f1d7
11 changed files with 2923 additions and 548 deletions

View file

@ -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*

View file

@ -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**

View file

@ -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' },
],
},
],
}

View file

@ -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'

440
src/collections/Bookings.ts Normal file
View file

@ -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<string, unknown>) => {
if (!note.author) {
note.author = req.user?.id
}
if (!note.createdAt) {
note.createdAt = new Date().toISOString()
}
return note
})
}
return data
},
],
},
}

View file

@ -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<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }
return map[char] || char
})
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
return data
},
],
},
}

View file

@ -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,
],
},
{

626
src/collections/Projects.ts Normal file
View file

@ -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<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }
return map[char] || char
})
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
return data
},
],
},
}

View file

@ -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<void> {
// ========================================
// 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<void> {
// 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;`)
}

View file

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

View file

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