mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
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:
parent
9e5c741941
commit
05fba7f1d7
11 changed files with 2923 additions and 548 deletions
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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,145 +49,17 @@
|
|||
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||
- [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`
|
||||
|
||||
- [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`
|
||||
|
||||
- [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)
|
||||
|
||||
### Niedrige Priorität
|
||||
|
||||
- [ ] **Analytics Integration**
|
||||
- **1. Umami Analytics (cookieless, ohne Consent)**
|
||||
#### 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
|
||||
|
|
@ -202,281 +70,72 @@
|
|||
- [ ] Event-Tracking in Newsletter-Formular integrieren
|
||||
- [ ] Event-Tracking in CTA-Buttons integrieren
|
||||
- [ ] TrackedButton & TrackedDownload Komponenten erstellen
|
||||
- **2. Google Ads Conversion (mit Consent)**
|
||||
|
||||
- [ ] **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**
|
||||
|
||||
- [ ] **Cookie Inventory**
|
||||
- [ ] Google Ads Cookies (_gcl_au, _gcl_aw, IDE) zur Cookie Inventory Collection hinzufügen
|
||||
- **4. Environment Variables**
|
||||
|
||||
- [ ] **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**
|
||||
|
|
|
|||
518
src/blocks/BeforeAfterBlock.ts
Normal file
518
src/blocks/BeforeAfterBlock.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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
440
src/collections/Bookings.ts
Normal 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
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
451
src/collections/Certifications.ts
Normal file
451
src/collections/Certifications.ts
Normal 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
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
@ -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
626
src/collections/Projects.ts
Normal 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
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
784
src/migrations/20251214_010000_tenant_specific_collections.ts
Normal file
784
src/migrations/20251214_010000_tenant_specific_collections.ts
Normal 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;`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in a new issue