mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
feat: add super admin role and update documentation
- Add isSuperAdmin field to Users collection with migration - Update API documentation with analytics examples - Add analytics implementation guide - Update TODO with completed tasks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d18e58de40
commit
dbe36ad381
8 changed files with 14696 additions and 10 deletions
963
docs/anleitungen/ANALYTICS_IMPLEMENTATION_GUIDE.md
Normal file
963
docs/anleitungen/ANALYTICS_IMPLEMENTATION_GUIDE.md
Normal file
|
|
@ -0,0 +1,963 @@
|
||||||
|
# ANALYTICS-LÖSUNG: Implementierungsübersicht für Payload CMS
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Du entwickelst das Multi-Tenant Payload CMS Backend und Next.js Frontend für 4 Websites. Diese Dokumentation beschreibt die implementierte Analytics-Lösung, die du in das Frontend integrieren musst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur-Übersicht
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ANALYTICS ARCHITEKTUR │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ OHNE CONSENT (immer aktiv) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ UMAMI ANALYTICS │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ Server: sv-analytics (10.10.181.103:3000) │ │ │
|
||||||
|
│ │ │ Dashboard: http://10.10.181.103:3000 │ │ │
|
||||||
|
│ │ │ Script: /custom.js (Anti-Adblock) │ │ │
|
||||||
|
│ │ │ Endpoint: /api/send │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ Features: │ │ │
|
||||||
|
│ │ │ • Cookieless Tracking (DSGVO-konform ohne Einwilligung) │ │ │
|
||||||
|
│ │ │ • Pageviews, Sessions, Referrer, UTM-Parameter │ │ │
|
||||||
|
│ │ │ • Custom Events (Newsletter, Formulare, CTAs, Downloads) │ │ │
|
||||||
|
│ │ │ • 100% Erfassung aller Besucher │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ MIT CONSENT (Kategorie: "marketing") │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ GOOGLE ADS CONVERSION │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ Client-Side (bei Consent): │ │ │
|
||||||
|
│ │ │ • Google Ads Tag (gtag.js) │ │ │
|
||||||
|
│ │ │ • Conversion Tracking │ │ │
|
||||||
|
│ │ │ • Remarketing Audiences │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ Server-Side (immer, anonymisiert): │ │ │
|
||||||
|
│ │ │ • Google Ads Conversion API │ │ │
|
||||||
|
│ │ │ • Enhanced Conversions (gehashte E-Mail) │ │ │
|
||||||
|
│ │ │ • GCLID-basierte Attribution │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ GOOGLE CONSENT MODE v2 │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ Integration mit bestehendem Orestbida Consent-Banner │ │ │
|
||||||
|
│ │ │ Kategorie "marketing" steuert: │ │ │
|
||||||
|
│ │ │ • ad_storage │ │ │
|
||||||
|
│ │ │ • ad_user_data │ │ │
|
||||||
|
│ │ │ • ad_personalization │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastruktur
|
||||||
|
|
||||||
|
### Umami Server
|
||||||
|
|
||||||
|
| Eigenschaft | Wert |
|
||||||
|
|-------------|------|
|
||||||
|
| **Server** | sv-analytics (LXC 703) |
|
||||||
|
| **IP** | 10.10.181.103 |
|
||||||
|
| **Port** | 3000 |
|
||||||
|
| **Dashboard** | http://10.10.181.103:3000 |
|
||||||
|
| **Tracking Script** | http://10.10.181.103:3000/custom.js |
|
||||||
|
| **API Endpoint** | http://10.10.181.103:3000/api/send |
|
||||||
|
| **Datenbank** | PostgreSQL auf sv-postgres (umami_analytics) |
|
||||||
|
|
||||||
|
### Website-IDs (Multi-Tenant)
|
||||||
|
|
||||||
|
Nach Umami-Setup werden diese IDs vergeben:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/config/analytics.ts
|
||||||
|
|
||||||
|
export const UMAMI_WEBSITE_IDS: Record<string, string> = {
|
||||||
|
'porwoll.de': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
||||||
|
'complexcaresolutions.de': 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy',
|
||||||
|
'gunshin.de': 'zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz',
|
||||||
|
'zweitmeinu.ng': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UMAMI_HOST = process.env.NEXT_PUBLIC_UMAMI_HOST || 'http://10.10.181.103:3000'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend-Integration
|
||||||
|
|
||||||
|
### 1. Umami Script Komponente
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/analytics/UmamiScript.tsx
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import Script from 'next/script'
|
||||||
|
|
||||||
|
interface UmamiScriptProps {
|
||||||
|
websiteId: string
|
||||||
|
host?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UmamiScript({
|
||||||
|
websiteId,
|
||||||
|
host = 'http://10.10.181.103:3000'
|
||||||
|
}: UmamiScriptProps) {
|
||||||
|
if (!websiteId) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Script
|
||||||
|
defer
|
||||||
|
src={`${host}/custom.js`}
|
||||||
|
data-website-id={websiteId}
|
||||||
|
data-host-url={host}
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Layout Integration (Multi-Tenant)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/layout.tsx
|
||||||
|
|
||||||
|
import { UmamiScript } from '@/components/analytics/UmamiScript'
|
||||||
|
import { UMAMI_WEBSITE_IDS, UMAMI_HOST } from '@/config/analytics'
|
||||||
|
import { getCurrentTenant } from '@/lib/tenant'
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const tenant = await getCurrentTenant()
|
||||||
|
const umamiWebsiteId = UMAMI_WEBSITE_IDS[tenant.domain]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Umami Analytics - Läuft OHNE Consent (cookieless) */}
|
||||||
|
{umamiWebsiteId && (
|
||||||
|
<UmamiScript
|
||||||
|
websiteId={umamiWebsiteId}
|
||||||
|
host={UMAMI_HOST}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Analytics Hook für Custom Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/hooks/useAnalytics.ts
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
umami?: {
|
||||||
|
track: (eventName: string, eventData?: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnalytics() {
|
||||||
|
/**
|
||||||
|
* Generisches Event-Tracking
|
||||||
|
*/
|
||||||
|
const trackEvent = useCallback((
|
||||||
|
eventName: string,
|
||||||
|
eventData?: Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
if (typeof window !== 'undefined' && window.umami) {
|
||||||
|
window.umami.track(eventName, eventData)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newsletter Anmeldung
|
||||||
|
*/
|
||||||
|
const trackNewsletterSubscribe = useCallback((source: string = 'unknown') => {
|
||||||
|
trackEvent('newsletter_subscribe', { source })
|
||||||
|
}, [trackEvent])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newsletter Bestätigung (Double Opt-In)
|
||||||
|
*/
|
||||||
|
const trackNewsletterConfirm = useCallback(() => {
|
||||||
|
trackEvent('newsletter_confirm')
|
||||||
|
}, [trackEvent])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kontaktformular abgesendet
|
||||||
|
*/
|
||||||
|
const trackContactFormSubmit = useCallback((formType: string = 'contact') => {
|
||||||
|
trackEvent('contact_form_submit', { form_type: formType })
|
||||||
|
}, [trackEvent])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CTA Klick
|
||||||
|
*/
|
||||||
|
const trackCtaClick = useCallback((
|
||||||
|
ctaName: string,
|
||||||
|
ctaLocation: string = 'unknown'
|
||||||
|
) => {
|
||||||
|
trackEvent('cta_click', { cta_name: ctaName, location: ctaLocation })
|
||||||
|
}, [trackEvent])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download
|
||||||
|
*/
|
||||||
|
const trackDownload = useCallback((
|
||||||
|
fileName: string,
|
||||||
|
fileType: string = 'unknown'
|
||||||
|
) => {
|
||||||
|
trackEvent('download', { file_name: fileName, file_type: fileType })
|
||||||
|
}, [trackEvent])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Funnel-Step (für Conversion-Funnels)
|
||||||
|
*/
|
||||||
|
const trackFunnelStep = useCallback((
|
||||||
|
funnelName: string,
|
||||||
|
stepNumber: number,
|
||||||
|
stepName: string
|
||||||
|
) => {
|
||||||
|
trackEvent('funnel_step', {
|
||||||
|
funnel: funnelName,
|
||||||
|
step: stepNumber,
|
||||||
|
step_name: stepName
|
||||||
|
})
|
||||||
|
}, [trackEvent])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll-Tiefe
|
||||||
|
*/
|
||||||
|
const trackScrollDepth = useCallback((depth: number) => {
|
||||||
|
trackEvent('scroll_depth', { depth_percent: depth })
|
||||||
|
}, [trackEvent])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Externer Link Klick
|
||||||
|
*/
|
||||||
|
const trackExternalLink = useCallback((url: string) => {
|
||||||
|
trackEvent('external_link', { url })
|
||||||
|
}, [trackEvent])
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackEvent,
|
||||||
|
trackNewsletterSubscribe,
|
||||||
|
trackNewsletterConfirm,
|
||||||
|
trackContactFormSubmit,
|
||||||
|
trackCtaClick,
|
||||||
|
trackDownload,
|
||||||
|
trackFunnelStep,
|
||||||
|
trackScrollDepth,
|
||||||
|
trackExternalLink,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Server-Side Event Tracking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/analytics.server.ts
|
||||||
|
|
||||||
|
const UMAMI_HOST = process.env.UMAMI_HOST || 'http://10.10.181.103:3000'
|
||||||
|
const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID
|
||||||
|
|
||||||
|
interface ServerEventParams {
|
||||||
|
event: string
|
||||||
|
url: string
|
||||||
|
websiteId?: string
|
||||||
|
referrer?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-Side Event an Umami senden
|
||||||
|
* Für Events die im Backend passieren (z.B. Newsletter-Bestätigung)
|
||||||
|
*/
|
||||||
|
export async function trackServerEvent(params: ServerEventParams) {
|
||||||
|
const websiteId = params.websiteId || UMAMI_WEBSITE_ID
|
||||||
|
|
||||||
|
if (!websiteId) {
|
||||||
|
console.warn('[Analytics] No websiteId configured for server-side tracking')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${UMAMI_HOST}/api/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'Payload-CMS-Server/1.0',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'event',
|
||||||
|
payload: {
|
||||||
|
website: websiteId,
|
||||||
|
url: params.url,
|
||||||
|
referrer: params.referrer || '',
|
||||||
|
name: params.event,
|
||||||
|
data: params.data,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('[Analytics] Server event failed:', response.status)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Analytics] Server event error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Komponenten-Beispiele
|
||||||
|
|
||||||
|
### Newsletter-Formular mit Tracking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/forms/NewsletterForm.tsx
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useAnalytics } from '@/hooks/useAnalytics'
|
||||||
|
|
||||||
|
interface NewsletterFormProps {
|
||||||
|
source?: string // z.B. 'footer', 'hero', 'popup'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewsletterForm({ source = 'unknown' }: NewsletterFormProps) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||||
|
const { trackNewsletterSubscribe, trackFunnelStep } = useAnalytics()
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setStatus('loading')
|
||||||
|
|
||||||
|
// Funnel-Step tracken
|
||||||
|
trackFunnelStep('newsletter', 1, 'form_submitted')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/newsletter/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, source }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setStatus('success')
|
||||||
|
// Erfolg tracken
|
||||||
|
trackNewsletterSubscribe(source)
|
||||||
|
trackFunnelStep('newsletter', 2, 'subscription_pending')
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'success') {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-green-50 text-green-800 rounded">
|
||||||
|
Bitte bestätige deine E-Mail-Adresse.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="E-Mail-Adresse"
|
||||||
|
required
|
||||||
|
className="flex-1 px-4 py-2 border rounded"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === 'loading'}
|
||||||
|
className="px-6 py-2 bg-primary text-white rounded"
|
||||||
|
>
|
||||||
|
{status === 'loading' ? 'Lädt...' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CTA Button mit Tracking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/ui/TrackedButton.tsx
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/hooks/useAnalytics'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
interface TrackedButtonProps {
|
||||||
|
href: string
|
||||||
|
ctaName: string
|
||||||
|
location?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
external?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackedButton({
|
||||||
|
href,
|
||||||
|
ctaName,
|
||||||
|
location = 'unknown',
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
external = false,
|
||||||
|
}: TrackedButtonProps) {
|
||||||
|
const { trackCtaClick, trackExternalLink } = useAnalytics()
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
trackCtaClick(ctaName, location)
|
||||||
|
if (external) {
|
||||||
|
trackExternalLink(href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (external) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={handleClick}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} onClick={handleClick} className={className}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download-Link mit Tracking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/ui/TrackedDownload.tsx
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useAnalytics } from '@/hooks/useAnalytics'
|
||||||
|
|
||||||
|
interface TrackedDownloadProps {
|
||||||
|
href: string
|
||||||
|
fileName: string
|
||||||
|
fileType?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackedDownload({
|
||||||
|
href,
|
||||||
|
fileName,
|
||||||
|
fileType = 'document',
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: TrackedDownloadProps) {
|
||||||
|
const { trackDownload } = useAnalytics()
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
trackDownload(fileName, fileType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
download
|
||||||
|
onClick={handleClick}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Google Ads Integration
|
||||||
|
|
||||||
|
### Consent Mode v2 Komponente
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/analytics/GoogleConsentMode.tsx
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import Script from 'next/script'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
interface GoogleConsentModeProps {
|
||||||
|
googleAdsId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoogleConsentMode({ googleAdsId }: GoogleConsentModeProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Consent-Änderungen von Orestbida abonnieren
|
||||||
|
window.addEventListener('cc:onConsent', handleConsentChange)
|
||||||
|
window.addEventListener('cc:onChange', handleConsentChange)
|
||||||
|
|
||||||
|
// Initial setzen
|
||||||
|
updateGoogleConsent()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('cc:onConsent', handleConsentChange)
|
||||||
|
window.removeEventListener('cc:onChange', handleConsentChange)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleConsentChange() {
|
||||||
|
updateGoogleConsent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGoogleConsent() {
|
||||||
|
if (typeof window.gtag !== 'function') return
|
||||||
|
|
||||||
|
const cc = window.CookieConsent
|
||||||
|
if (!cc) return
|
||||||
|
|
||||||
|
const hasMarketing = cc.acceptedCategory('marketing')
|
||||||
|
|
||||||
|
window.gtag('consent', 'update', {
|
||||||
|
'ad_storage': hasMarketing ? 'granted' : 'denied',
|
||||||
|
'ad_user_data': hasMarketing ? 'granted' : 'denied',
|
||||||
|
'ad_personalization': hasMarketing ? 'granted' : 'denied',
|
||||||
|
'analytics_storage': 'denied', // Wir nutzen Umami
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Consent Default (vor gtag.js) */}
|
||||||
|
<Script
|
||||||
|
id="google-consent-default"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
|
||||||
|
gtag('consent', 'default', {
|
||||||
|
'ad_storage': 'denied',
|
||||||
|
'ad_user_data': 'denied',
|
||||||
|
'ad_personalization': 'denied',
|
||||||
|
'analytics_storage': 'denied',
|
||||||
|
'wait_for_update': 500
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Google Ads Tag */}
|
||||||
|
<Script
|
||||||
|
src={`https://www.googletagmanager.com/gtag/js?id=${googleAdsId}`}
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Script
|
||||||
|
id="google-ads-config"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '${googleAdsId}', {
|
||||||
|
'allow_enhanced_conversions': true
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GCLID Hook (für Conversion Attribution)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/hooks/useGclid.ts
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
|
||||||
|
const GCLID_STORAGE_KEY = 'gclid'
|
||||||
|
const GCLID_EXPIRY_DAYS = 90
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GCLID aus URL erfassen und speichern
|
||||||
|
*/
|
||||||
|
export function useGclid() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const gclid = searchParams.get('gclid')
|
||||||
|
|
||||||
|
if (gclid) {
|
||||||
|
const data = {
|
||||||
|
value: gclid,
|
||||||
|
expires: Date.now() + (GCLID_EXPIRY_DAYS * 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
localStorage.setItem(GCLID_STORAGE_KEY, JSON.stringify(data))
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gespeicherte GCLID abrufen
|
||||||
|
*/
|
||||||
|
export function getStoredGclid(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(GCLID_STORAGE_KEY)
|
||||||
|
if (!stored) return null
|
||||||
|
|
||||||
|
const data = JSON.parse(stored)
|
||||||
|
|
||||||
|
if (Date.now() > data.expires) {
|
||||||
|
localStorage.removeItem(GCLID_STORAGE_KEY)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.value
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client-Side Conversion Tracking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/google-ads.ts
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
gtag: (...args: unknown[]) => void
|
||||||
|
CookieConsent: {
|
||||||
|
acceptedCategory: (category: string) => boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversionParams {
|
||||||
|
conversionId: string
|
||||||
|
value?: number
|
||||||
|
currency?: string
|
||||||
|
transactionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-Side Conversion (nur bei Marketing-Consent)
|
||||||
|
*/
|
||||||
|
export function trackConversion(params: ConversionParams) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (typeof window.gtag !== 'function') return
|
||||||
|
|
||||||
|
const hasConsent = window.CookieConsent?.acceptedCategory('marketing')
|
||||||
|
if (!hasConsent) return
|
||||||
|
|
||||||
|
window.gtag('event', 'conversion', {
|
||||||
|
'send_to': params.conversionId,
|
||||||
|
'value': params.value || 1.0,
|
||||||
|
'currency': params.currency || 'EUR',
|
||||||
|
'transaction_id': params.transactionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced Conversion Data setzen
|
||||||
|
*/
|
||||||
|
export function setEnhancedConversionData(data: {
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
}) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (typeof window.gtag !== 'function') return
|
||||||
|
|
||||||
|
const hasConsent = window.CookieConsent?.acceptedCategory('marketing')
|
||||||
|
if (!hasConsent) return
|
||||||
|
|
||||||
|
window.gtag('set', 'user_data', {
|
||||||
|
'email': data.email,
|
||||||
|
'phone_number': data.phone,
|
||||||
|
'address': {
|
||||||
|
'first_name': data.firstName,
|
||||||
|
'last_name': data.lastName,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server-Side Conversion API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/google-ads.server.ts
|
||||||
|
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
const GOOGLE_ADS_CUSTOMER_ID = process.env.GOOGLE_ADS_CUSTOMER_ID
|
||||||
|
const GOOGLE_ADS_CONVERSION_ACTION_ID = process.env.GOOGLE_ADS_CONVERSION_ACTION_ID
|
||||||
|
const GOOGLE_ADS_API_TOKEN = process.env.GOOGLE_ADS_API_TOKEN
|
||||||
|
const GOOGLE_ADS_DEVELOPER_TOKEN = process.env.GOOGLE_ADS_DEVELOPER_TOKEN
|
||||||
|
|
||||||
|
interface ServerConversionParams {
|
||||||
|
conversionAction: string
|
||||||
|
email?: string
|
||||||
|
phone?: string
|
||||||
|
value?: number
|
||||||
|
currency?: string
|
||||||
|
gclid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-Side Conversion Upload
|
||||||
|
* DSGVO-konform: Nur gehashte Daten
|
||||||
|
*/
|
||||||
|
export async function trackServerConversion(params: ServerConversionParams) {
|
||||||
|
if (!GOOGLE_ADS_CUSTOMER_ID || !GOOGLE_ADS_API_TOKEN) {
|
||||||
|
console.log('[Google Ads] Server-Side nicht konfiguriert')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversion: Record<string, unknown> = {
|
||||||
|
conversionAction: `customers/${GOOGLE_ADS_CUSTOMER_ID}/conversionActions/${GOOGLE_ADS_CONVERSION_ACTION_ID}`,
|
||||||
|
conversionDateTime: new Date().toISOString().replace('Z', '+00:00'),
|
||||||
|
conversionValue: params.value || 1.0,
|
||||||
|
currencyCode: params.currency || 'EUR',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.gclid) {
|
||||||
|
conversion.gclid = params.gclid
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.email || params.phone) {
|
||||||
|
conversion.userIdentifiers = []
|
||||||
|
|
||||||
|
if (params.email) {
|
||||||
|
(conversion.userIdentifiers as Array<unknown>).push({
|
||||||
|
hashedEmail: hashForGoogle(params.email.toLowerCase().trim())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.phone) {
|
||||||
|
(conversion.userIdentifiers as Array<unknown>).push({
|
||||||
|
hashedPhoneNumber: hashForGoogle(params.phone.replace(/[^0-9+]/g, ''))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://googleads.googleapis.com/v15/customers/${GOOGLE_ADS_CUSTOMER_ID}:uploadConversionAdjustments`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${GOOGLE_ADS_API_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'developer-token': GOOGLE_ADS_DEVELOPER_TOKEN!,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversions: [conversion],
|
||||||
|
partialFailure: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('[Google Ads] Upload failed:', await response.text())
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Google Ads] Error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashForGoogle(value: string): string {
|
||||||
|
return crypto.createHash('sha256').update(value).digest('hex')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CookieInventory Erweiterung
|
||||||
|
|
||||||
|
Füge diese Cookies zur `cookie-inventory` Collection hinzu:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Über Payload Admin oder Seed-Script
|
||||||
|
|
||||||
|
const googleAdsCookies = [
|
||||||
|
{
|
||||||
|
name: '_gcl_au',
|
||||||
|
provider: 'Google Ads',
|
||||||
|
category: 'marketing',
|
||||||
|
purpose: 'Conversion-Tracking und Anzeigenmessung',
|
||||||
|
duration: '90 Tage',
|
||||||
|
type: 'first-party',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '_gcl_aw',
|
||||||
|
provider: 'Google Ads',
|
||||||
|
category: 'marketing',
|
||||||
|
purpose: 'Speichert GCLID für Conversion-Attribution',
|
||||||
|
duration: '90 Tage',
|
||||||
|
type: 'first-party',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'IDE',
|
||||||
|
provider: 'Google (doubleclick.net)',
|
||||||
|
category: 'marketing',
|
||||||
|
purpose: 'Remarketing und Anzeigenpersonalisierung',
|
||||||
|
duration: '1 Jahr',
|
||||||
|
type: 'third-party',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Frontend (.env.local)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Umami Analytics
|
||||||
|
NEXT_PUBLIC_UMAMI_HOST=http://10.10.181.103:3000
|
||||||
|
|
||||||
|
# Google Ads (pro Tenant unterschiedlich)
|
||||||
|
NEXT_PUBLIC_GOOGLE_ADS_ID=AW-XXXXXXXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Umami Server-Side Tracking
|
||||||
|
UMAMI_HOST=http://10.10.181.103:3000
|
||||||
|
UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Google Ads Server-Side API
|
||||||
|
GOOGLE_ADS_CUSTOMER_ID=1234567890
|
||||||
|
GOOGLE_ADS_CONVERSION_ACTION_ID=987654321
|
||||||
|
GOOGLE_ADS_API_TOKEN=ya29.xxx
|
||||||
|
GOOGLE_ADS_DEVELOPER_TOKEN=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event-Naming-Konvention
|
||||||
|
|
||||||
|
| Event | Name | Data |
|
||||||
|
|-------|------|------|
|
||||||
|
| Newsletter Anmeldung | `newsletter_subscribe` | `{ source: string }` |
|
||||||
|
| Newsletter Bestätigung | `newsletter_confirm` | - |
|
||||||
|
| Kontaktformular | `contact_form_submit` | `{ form_type: string }` |
|
||||||
|
| CTA Klick | `cta_click` | `{ cta_name: string, location: string }` |
|
||||||
|
| Download | `download` | `{ file_name: string, file_type: string }` |
|
||||||
|
| Funnel-Step | `funnel_step` | `{ funnel: string, step: number, step_name: string }` |
|
||||||
|
| Scroll-Tiefe | `scroll_depth` | `{ depth_percent: number }` |
|
||||||
|
| Externer Link | `external_link` | `{ url: string }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zusammenfassung
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ WAS IMPLEMENTIERT WERDEN MUSS │
|
||||||
|
│ │
|
||||||
|
│ 1. UMAMI (ohne Consent) │
|
||||||
|
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||||
|
│ • UmamiScript Komponente in Layout einbinden │
|
||||||
|
│ • useAnalytics Hook in Formulare/CTAs integrieren │
|
||||||
|
│ • Server-Side Events für Backend-Actions │
|
||||||
|
│ │
|
||||||
|
│ 2. GOOGLE ADS (mit Consent) │
|
||||||
|
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||||
|
│ • GoogleConsentMode Komponente (integriert mit Orestbida) │
|
||||||
|
│ • useGclid Hook für Attribution │
|
||||||
|
│ • Client-Side Conversions bei Consent │
|
||||||
|
│ • Server-Side Conversions immer (gehashte Daten) │
|
||||||
|
│ │
|
||||||
|
│ 3. COOKIE INVENTORY │
|
||||||
|
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||||
|
│ • Google Ads Cookies dokumentieren │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dateien zu erstellen
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── analytics/
|
||||||
|
│ │ ├── UmamiScript.tsx # Umami Tracking Script
|
||||||
|
│ │ └── GoogleConsentMode.tsx # Google Consent Mode v2
|
||||||
|
│ ├── forms/
|
||||||
|
│ │ └── NewsletterForm.tsx # Mit Analytics-Integration
|
||||||
|
│ └── ui/
|
||||||
|
│ ├── TrackedButton.tsx # CTA mit Tracking
|
||||||
|
│ └── TrackedDownload.tsx # Download mit Tracking
|
||||||
|
├── config/
|
||||||
|
│ └── analytics.ts # Website-IDs, Config
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useAnalytics.ts # Client-Side Event Tracking
|
||||||
|
│ └── useGclid.ts # GCLID Erfassung
|
||||||
|
└── lib/
|
||||||
|
├── analytics.server.ts # Umami Server-Side
|
||||||
|
├── google-ads.ts # Google Ads Client
|
||||||
|
└── google-ads.server.ts # Google Ads Server API
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stand: Dezember 2025*
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Übersicht
|
## Übersicht
|
||||||
|
|
||||||
Das Payload CMS stellt eine REST-API und eine GraphQL-API bereit. Diese Anleitung beschreibt die Nutzung der REST-API für die Universal Features.
|
Das Payload CMS stellt eine REST-API und eine GraphQL-API bereit. Diese Anleitung beschreibt die Nutzung der REST-API für alle Collections.
|
||||||
|
|
||||||
**Base URL:** `https://pl.c2sgmbh.de/api`
|
**Base URL:** `https://pl.c2sgmbh.de/api`
|
||||||
|
|
||||||
|
|
@ -25,7 +25,12 @@ curl -X POST "https://pl.c2sgmbh.de/api/users/login" \
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"message": "Auth Passed",
|
"message": "Auth Passed",
|
||||||
"user": { ... },
|
"user": {
|
||||||
|
"id": 1,
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"isSuperAdmin": true,
|
||||||
|
"tenants": [...]
|
||||||
|
},
|
||||||
"token": "eyJhbGciOiJIUzI1NiIs..."
|
"token": "eyJhbGciOiJIUzI1NiIs..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -39,6 +44,77 @@ curl "https://pl.c2sgmbh.de/api/posts" \
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Mehrsprachigkeit (Localization)
|
||||||
|
|
||||||
|
Das CMS unterstützt Deutsch (de) und Englisch (en). Lokalisierte Felder können über den `locale` Parameter abgerufen werden.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deutsche Inhalte (Standard)
|
||||||
|
curl "https://pl.c2sgmbh.de/api/posts?locale=de"
|
||||||
|
|
||||||
|
# Englische Inhalte
|
||||||
|
curl "https://pl.c2sgmbh.de/api/posts?locale=en"
|
||||||
|
|
||||||
|
# Alle Sprachen gleichzeitig
|
||||||
|
curl "https://pl.c2sgmbh.de/api/posts?locale=all"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Users API
|
||||||
|
|
||||||
|
### Aktuellen User abrufen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://pl.c2sgmbh.de/api/users/me" \
|
||||||
|
-H "Authorization: JWT your-token"
|
||||||
|
```
|
||||||
|
|
||||||
|
### User-Felder
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|------|-----|--------------|
|
||||||
|
| `email` | string | E-Mail-Adresse (eindeutig) |
|
||||||
|
| `isSuperAdmin` | boolean | Super Admin hat Zugriff auf alle Tenants |
|
||||||
|
| `tenants` | array | Zugewiesene Tenants |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tenants API
|
||||||
|
|
||||||
|
### Alle Tenants abrufen (Auth + SuperAdmin erforderlich)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://pl.c2sgmbh.de/api/tenants" \
|
||||||
|
-H "Authorization: JWT your-token"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tenant erstellen (Auth + SuperAdmin erforderlich)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://pl.c2sgmbh.de/api/tenants" \
|
||||||
|
-H "Authorization: JWT your-token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Meine Firma GmbH",
|
||||||
|
"slug": "meine-firma",
|
||||||
|
"domains": [
|
||||||
|
{ "domain": "meine-firma.de" },
|
||||||
|
{ "domain": "www.meine-firma.de" }
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tenant-Felder
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|------|-----|--------------|
|
||||||
|
| `name` | string | Anzeigename des Tenants |
|
||||||
|
| `slug` | string | URL-freundlicher Identifier (eindeutig) |
|
||||||
|
| `domains` | array | Zugeordnete Domains |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Posts API
|
## Posts API
|
||||||
|
|
||||||
### Alle Posts abrufen
|
### Alle Posts abrufen
|
||||||
|
|
@ -67,6 +143,9 @@ curl "https://pl.c2sgmbh.de/api/posts?limit=10"
|
||||||
|
|
||||||
# Pagination (Seite 2)
|
# Pagination (Seite 2)
|
||||||
curl "https://pl.c2sgmbh.de/api/posts?limit=10&page=2"
|
curl "https://pl.c2sgmbh.de/api/posts?limit=10&page=2"
|
||||||
|
|
||||||
|
# Mit Locale
|
||||||
|
curl "https://pl.c2sgmbh.de/api/posts?locale=de"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Einzelnen Post abrufen
|
### Einzelnen Post abrufen
|
||||||
|
|
@ -263,6 +342,9 @@ curl "https://pl.c2sgmbh.de/api/pages?where[slug][equals]=startseite"
|
||||||
|
|
||||||
# Nur veröffentlichte Seiten
|
# Nur veröffentlichte Seiten
|
||||||
curl "https://pl.c2sgmbh.de/api/pages?where[status][equals]=published"
|
curl "https://pl.c2sgmbh.de/api/pages?where[status][equals]=published"
|
||||||
|
|
||||||
|
# Mit Locale
|
||||||
|
curl "https://pl.c2sgmbh.de/api/pages?locale=de&depth=2"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Seite mit Blocks
|
### Seite mit Blocks
|
||||||
|
|
@ -299,6 +381,186 @@ Die Blocks werden im `layout`-Array zurückgegeben:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Consent Management APIs
|
||||||
|
|
||||||
|
### Cookie-Konfiguration abrufen (Öffentlich, Tenant-isoliert)
|
||||||
|
|
||||||
|
Die Cookie-Konfiguration wird automatisch nach Domain gefiltert.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Für Frontend Cookie-Banner
|
||||||
|
curl "https://pl.c2sgmbh.de/api/cookie-configurations?where[tenant][equals]=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"docs": [{
|
||||||
|
"id": 1,
|
||||||
|
"tenant": 1,
|
||||||
|
"title": "Cookie-Einstellungen",
|
||||||
|
"revision": 1,
|
||||||
|
"enabledCategories": ["necessary", "analytics"],
|
||||||
|
"translations": {
|
||||||
|
"de": {
|
||||||
|
"bannerTitle": "Wir respektieren Ihre Privatsphäre",
|
||||||
|
"bannerDescription": "Diese Website verwendet Cookies...",
|
||||||
|
"acceptAllButton": "Alle akzeptieren",
|
||||||
|
"acceptNecessaryButton": "Nur notwendige",
|
||||||
|
"settingsButton": "Einstellungen",
|
||||||
|
"saveButton": "Auswahl speichern",
|
||||||
|
"privacyPolicyUrl": "/datenschutz",
|
||||||
|
"categoryLabels": {
|
||||||
|
"necessary": { "title": "Notwendig", "description": "..." },
|
||||||
|
"functional": { "title": "Funktional", "description": "..." },
|
||||||
|
"analytics": { "title": "Statistik", "description": "..." },
|
||||||
|
"marketing": { "title": "Marketing", "description": "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"styling": {
|
||||||
|
"position": "bottom",
|
||||||
|
"theme": "dark"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cookie-Inventory abrufen (Öffentlich, Tenant-isoliert)
|
||||||
|
|
||||||
|
Dokumentation aller verwendeten Cookies für die Datenschutzerklärung.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle Cookies eines Tenants
|
||||||
|
curl "https://pl.c2sgmbh.de/api/cookie-inventory?where[tenant][equals]=1"
|
||||||
|
|
||||||
|
# Nur aktive Cookies
|
||||||
|
curl "https://pl.c2sgmbh.de/api/cookie-inventory?where[tenant][equals]=1&where[isActive][equals]=true"
|
||||||
|
|
||||||
|
# Nach Kategorie filtern
|
||||||
|
curl "https://pl.c2sgmbh.de/api/cookie-inventory?where[tenant][equals]=1&where[category][equals]=analytics"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"docs": [{
|
||||||
|
"id": 1,
|
||||||
|
"tenant": 1,
|
||||||
|
"name": "_ga",
|
||||||
|
"provider": "Google LLC",
|
||||||
|
"category": "analytics",
|
||||||
|
"duration": "2 Jahre",
|
||||||
|
"description": "Wird verwendet, um Benutzer zu unterscheiden.",
|
||||||
|
"isActive": true
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cookie-Inventory Felder
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|------|-----|--------------|
|
||||||
|
| `name` | string | Technischer Cookie-Name (z.B. "_ga") |
|
||||||
|
| `provider` | string | Anbieter (z.B. "Google LLC") |
|
||||||
|
| `category` | enum | necessary, functional, analytics, marketing |
|
||||||
|
| `duration` | string | Speicherdauer (z.B. "2 Jahre") |
|
||||||
|
| `description` | string | Beschreibung für Endnutzer |
|
||||||
|
| `isActive` | boolean | Cookie aktiv? |
|
||||||
|
|
||||||
|
### Consent-Log erstellen (API-Key erforderlich)
|
||||||
|
|
||||||
|
Consent-Logs sind ein WORM (Write-Once-Read-Many) Audit-Trail für DSGVO-Nachweise.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://pl.c2sgmbh.de/api/consent-logs" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: your-consent-api-key" \
|
||||||
|
-d '{
|
||||||
|
"tenant": 1,
|
||||||
|
"clientRef": "uuid-from-cookie",
|
||||||
|
"categories": {
|
||||||
|
"necessary": true,
|
||||||
|
"functional": false,
|
||||||
|
"analytics": true,
|
||||||
|
"marketing": false
|
||||||
|
},
|
||||||
|
"revision": 1,
|
||||||
|
"userAgent": "Mozilla/5.0..."
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"doc": {
|
||||||
|
"id": 1,
|
||||||
|
"consentId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"tenant": 1,
|
||||||
|
"clientRef": "uuid-from-cookie",
|
||||||
|
"categories": { "necessary": true, "analytics": true, ... },
|
||||||
|
"revision": 1,
|
||||||
|
"anonymizedIp": "a1b2c3d4e5f6...",
|
||||||
|
"expiresAt": "2028-12-02T00:00:00.000Z",
|
||||||
|
"createdAt": "2025-12-02T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:**
|
||||||
|
- CREATE: Nur mit gültigem `X-API-Key` Header
|
||||||
|
- READ: Nur authentifizierte Admin-User
|
||||||
|
- UPDATE: **Nicht erlaubt** (WORM-Prinzip)
|
||||||
|
- DELETE: **Nicht erlaubt** (nur via Retention-Job)
|
||||||
|
|
||||||
|
### Consent-Log Felder
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung |
|
||||||
|
|------|-----|--------------|
|
||||||
|
| `consentId` | string | Server-generierte UUID (read-only) |
|
||||||
|
| `clientRef` | string | Client-Cookie-Referenz für Traceability |
|
||||||
|
| `tenant` | relation | Zugehöriger Tenant |
|
||||||
|
| `categories` | json | Akzeptierte Kategorien |
|
||||||
|
| `revision` | number | Konfigurationsversion zum Zeitpunkt der Zustimmung |
|
||||||
|
| `userAgent` | string | Browser-Information |
|
||||||
|
| `anonymizedIp` | string | HMAC-Hash der IP (täglich rotierend) |
|
||||||
|
| `expiresAt` | date | Automatische Löschung nach 3 Jahren |
|
||||||
|
|
||||||
|
### Privacy Policy Settings abrufen (Öffentlich, Tenant-isoliert)
|
||||||
|
|
||||||
|
Konfiguration für die Datenschutzerklärungs-Seite (z.B. Alfright Integration).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://pl.c2sgmbh.de/api/privacy-policy-settings?where[tenant][equals]=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"docs": [{
|
||||||
|
"id": 1,
|
||||||
|
"tenant": 1,
|
||||||
|
"title": "Datenschutzerklärung",
|
||||||
|
"provider": "alfright",
|
||||||
|
"alfright": {
|
||||||
|
"tenantId": "alfright_schutzteam",
|
||||||
|
"apiKey": "9f315103c43245bcb0806dd56c2be757",
|
||||||
|
"language": "de-de",
|
||||||
|
"iframeHeight": 4000
|
||||||
|
},
|
||||||
|
"styling": {
|
||||||
|
"headerColor": "#ca8a04",
|
||||||
|
"backgroundColor": "#111827",
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"showCookieTable": true,
|
||||||
|
"cookieTableTitle": "Übersicht der verwendeten Cookies"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Query-Parameter
|
## Query-Parameter
|
||||||
|
|
||||||
### Filterung (where)
|
### Filterung (where)
|
||||||
|
|
@ -368,6 +630,19 @@ Die Blocks werden im `layout`-Array zurückgegeben:
|
||||||
?depth=2
|
?depth=2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Locale (Mehrsprachigkeit)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deutsch (Standard)
|
||||||
|
?locale=de
|
||||||
|
|
||||||
|
# Englisch
|
||||||
|
?locale=en
|
||||||
|
|
||||||
|
# Alle Sprachen
|
||||||
|
?locale=all
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fehlerbehandlung
|
## Fehlerbehandlung
|
||||||
|
|
@ -390,7 +665,7 @@ Die Blocks werden im `layout`-Array zurückgegeben:
|
||||||
{
|
{
|
||||||
"errors": [
|
"errors": [
|
||||||
{
|
{
|
||||||
"message": "You are not allowed to perform this action."
|
"message": "Du hast keine Berechtigung, diese Aktion auszuführen."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -433,6 +708,10 @@ Für direkte API-Zugriffe:
|
||||||
curl "https://pl.c2sgmbh.de/api/posts?where[tenant][equals]=1"
|
curl "https://pl.c2sgmbh.de/api/posts?where[tenant][equals]=1"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Super Admin
|
||||||
|
|
||||||
|
User mit `isSuperAdmin: true` haben Zugriff auf alle Tenants und können neue Tenants erstellen/bearbeiten.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Beispiel: Frontend-Integration
|
## Beispiel: Frontend-Integration
|
||||||
|
|
@ -444,13 +723,14 @@ curl "https://pl.c2sgmbh.de/api/posts?where[tenant][equals]=1"
|
||||||
const API_BASE = 'https://pl.c2sgmbh.de/api'
|
const API_BASE = 'https://pl.c2sgmbh.de/api'
|
||||||
const TENANT_ID = 1
|
const TENANT_ID = 1
|
||||||
|
|
||||||
export async function getPosts(type?: string, limit = 10) {
|
export async function getPosts(type?: string, limit = 10, locale = 'de') {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
'where[tenant][equals]': String(TENANT_ID),
|
'where[tenant][equals]': String(TENANT_ID),
|
||||||
'where[status][equals]': 'published',
|
'where[status][equals]': 'published',
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
sort: '-publishedAt',
|
sort: '-publishedAt',
|
||||||
depth: '1',
|
depth: '1',
|
||||||
|
locale,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (type && type !== 'all') {
|
if (type && type !== 'all') {
|
||||||
|
|
@ -474,6 +754,50 @@ export async function getTestimonials(limit = 6) {
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCookieConfig() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
'where[tenant][equals]': String(TENANT_ID),
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/cookie-configurations?${params}`)
|
||||||
|
const data = await res.json()
|
||||||
|
return data.docs[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCookieInventory() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
'where[tenant][equals]': String(TENANT_ID),
|
||||||
|
'where[isActive][equals]': 'true',
|
||||||
|
sort: 'category',
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/cookie-inventory?${params}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logConsent(
|
||||||
|
categories: Record<string, boolean>,
|
||||||
|
revision: number,
|
||||||
|
clientRef: string,
|
||||||
|
apiKey: string
|
||||||
|
) {
|
||||||
|
const res = await fetch(`${API_BASE}/consent-logs`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant: TENANT_ID,
|
||||||
|
clientRef,
|
||||||
|
categories,
|
||||||
|
revision,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function subscribeNewsletter(email: string, source: string) {
|
export async function subscribeNewsletter(email: string, source: string) {
|
||||||
const res = await fetch(`${API_BASE}/newsletter-subscribers`, {
|
const res = await fetch(`${API_BASE}/newsletter-subscribers`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -543,6 +867,26 @@ Aktuell gibt es kein Rate Limiting. Für Production-Umgebungen sollte ein Revers
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Alle Collections Übersicht
|
||||||
|
|
||||||
|
| Collection | Slug | Öffentlich | Beschreibung |
|
||||||
|
|------------|------|------------|--------------|
|
||||||
|
| Users | `users` | Nein | Benutzer-Verwaltung |
|
||||||
|
| Tenants | `tenants` | Nein | Mandanten |
|
||||||
|
| Media | `media` | Ja | Bilder und Dateien |
|
||||||
|
| Pages | `pages` | Ja | Seiten mit Blocks |
|
||||||
|
| Posts | `posts` | Ja | Blog-Artikel und News |
|
||||||
|
| Categories | `categories` | Ja | Kategorien für Posts |
|
||||||
|
| Social Links | `social-links` | Ja | Social Media Links |
|
||||||
|
| Testimonials | `testimonials` | Ja | Kundenbewertungen |
|
||||||
|
| Newsletter Subscribers | `newsletter-subscribers` | Create: Ja | Newsletter-Anmeldungen |
|
||||||
|
| Cookie Configurations | `cookie-configurations` | Ja (Tenant-isoliert) | Cookie-Banner Konfiguration |
|
||||||
|
| Cookie Inventory | `cookie-inventory` | Ja (Tenant-isoliert) | Cookie-Dokumentation |
|
||||||
|
| Consent Logs | `consent-logs` | Nein (API-Key) | WORM Audit-Trail |
|
||||||
|
| Privacy Policy Settings | `privacy-policy-settings` | Ja (Tenant-isoliert) | Datenschutzerklärungs-Konfiguration |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Weitere Ressourcen
|
## Weitere Ressourcen
|
||||||
|
|
||||||
- **Admin Panel:** https://pl.c2sgmbh.de/admin
|
- **Admin Panel:** https://pl.c2sgmbh.de/admin
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,32 @@
|
||||||
### Niedrige Priorität
|
### Niedrige Priorität
|
||||||
|
|
||||||
- [ ] **Analytics Integration**
|
- [ ] **Analytics Integration**
|
||||||
- Google Analytics 4 / Plausible
|
- **1. Umami Analytics (cookieless, ohne Consent)**
|
||||||
- Event-Tracking für Newsletter
|
- [ ] Umami-Server auf sv-analytics (10.10.181.103) einrichten
|
||||||
- Conversion-Tracking
|
- [ ] Website-IDs für alle 4 Tenants in Umami erstellen
|
||||||
|
- [ ] `src/config/analytics.ts` mit Website-IDs anlegen
|
||||||
|
- [ ] `src/components/analytics/UmamiScript.tsx` implementieren
|
||||||
|
- [ ] Umami Script in Root Layout einbinden (Multi-Tenant)
|
||||||
|
- [ ] `src/hooks/useAnalytics.ts` Hook für Custom Events
|
||||||
|
- [ ] `src/lib/analytics.server.ts` für Server-Side Events
|
||||||
|
- [ ] Event-Tracking in Newsletter-Formular integrieren
|
||||||
|
- [ ] Event-Tracking in CTA-Buttons integrieren
|
||||||
|
- [ ] TrackedButton & TrackedDownload Komponenten erstellen
|
||||||
|
- **2. Google Ads Conversion (mit Consent)**
|
||||||
|
- [ ] `src/components/analytics/GoogleConsentMode.tsx` implementieren
|
||||||
|
- [ ] Google Consent Mode v2 mit Orestbida Cookie-Banner integrieren
|
||||||
|
- [ ] `src/hooks/useGclid.ts` Hook für GCLID-Erfassung
|
||||||
|
- [ ] `src/lib/google-ads.ts` Client-Side Conversion Tracking
|
||||||
|
- [ ] `src/lib/google-ads.server.ts` Server-Side Conversion API
|
||||||
|
- [ ] Enhanced Conversions mit gehashten E-Mails
|
||||||
|
- **3. Cookie Inventory**
|
||||||
|
- [ ] Google Ads Cookies (_gcl_au, _gcl_aw, IDE) zur Cookie Inventory Collection hinzufügen
|
||||||
|
- **4. Environment Variables**
|
||||||
|
- [ ] NEXT_PUBLIC_UMAMI_HOST in .env.local
|
||||||
|
- [ ] NEXT_PUBLIC_GOOGLE_ADS_ID in .env.local (pro Tenant)
|
||||||
|
- [ ] UMAMI_HOST, UMAMI_WEBSITE_ID in Backend .env
|
||||||
|
- [ ] Google Ads API Credentials in Backend .env
|
||||||
|
- Dokumentation: `docs/anleitungen/ANALYTICS_IMPLEMENTATION_GUIDE.md`
|
||||||
|
|
||||||
- [ ] **Caching-Strategie**
|
- [ ] **Caching-Strategie**
|
||||||
- Redis-Cache für API
|
- Redis-Cache für API
|
||||||
|
|
@ -191,6 +214,7 @@
|
||||||
- [x] TODO.md (Diese Datei)
|
- [x] TODO.md (Diese Datei)
|
||||||
- [x] BILDOPTIMIERUNG.md (Sharp & Image Sizes)
|
- [x] BILDOPTIMIERUNG.md (Sharp & Image Sizes)
|
||||||
- [x] SEO_ERWEITERUNG.md (SEO Features)
|
- [x] SEO_ERWEITERUNG.md (SEO Features)
|
||||||
|
- [x] ANALYTICS_IMPLEMENTATION_GUIDE.md (Umami & Google Ads)
|
||||||
- [ ] DEPLOYMENT.md (Deployment-Prozess)
|
- [ ] DEPLOYMENT.md (Deployment-Prozess)
|
||||||
- [ ] FRONTEND_INTEGRATION.md (Next.js Guide)
|
- [ ] FRONTEND_INTEGRATION.md (Next.js Guide)
|
||||||
- [ ] SECURITY.md (Sicherheitsrichtlinien)
|
- [ ] SECURITY.md (Sicherheitsrichtlinien)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,15 @@ export const Users: CollectionConfig = {
|
||||||
},
|
},
|
||||||
auth: true,
|
auth: true,
|
||||||
fields: [
|
fields: [
|
||||||
// Email added by default
|
{
|
||||||
// Add more fields as needed
|
name: 'isSuperAdmin',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'Super Admin',
|
||||||
|
defaultValue: false,
|
||||||
|
admin: {
|
||||||
|
description: 'Super Admins haben Zugriff auf alle Tenants und können neue Tenants erstellen.',
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13294
src/migrations/20251202_081830_add_is_super_admin_to_users.json
Normal file
13294
src/migrations/20251202_081830_add_is_super_admin_to_users.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TYPE "public"."enum_forms_redirect_type" AS ENUM('reference', 'custom');
|
||||||
|
CREATE TABLE "forms_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"pages_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "redirects_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"pages_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "users" ADD COLUMN "is_super_admin" boolean DEFAULT false;
|
||||||
|
ALTER TABLE "forms" ADD COLUMN "redirect_type" "enum_forms_redirect_type" DEFAULT 'reference';
|
||||||
|
ALTER TABLE "forms_rels" ADD CONSTRAINT "forms_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "forms_rels" ADD CONSTRAINT "forms_rels_pages_fk" FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "redirects_rels" ADD CONSTRAINT "redirects_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."redirects"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "redirects_rels" ADD CONSTRAINT "redirects_rels_pages_fk" FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
CREATE INDEX "forms_rels_order_idx" ON "forms_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "forms_rels_parent_idx" ON "forms_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "forms_rels_path_idx" ON "forms_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "forms_rels_pages_id_idx" ON "forms_rels" USING btree ("pages_id");
|
||||||
|
CREATE INDEX "redirects_rels_order_idx" ON "redirects_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "redirects_rels_parent_idx" ON "redirects_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "redirects_rels_path_idx" ON "redirects_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "redirects_rels_pages_id_idx" ON "redirects_rels" USING btree ("pages_id");`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
DROP TABLE "forms_rels" CASCADE;
|
||||||
|
DROP TABLE "redirects_rels" CASCADE;
|
||||||
|
ALTER TABLE "users" DROP COLUMN "is_super_admin";
|
||||||
|
ALTER TABLE "forms" DROP COLUMN "redirect_type";
|
||||||
|
DROP TYPE "public"."enum_forms_redirect_type";`)
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import * as migration_20251130_213501_initial_with_localization from './20251130_213501_initial_with_localization';
|
import * as migration_20251130_213501_initial_with_localization from './20251130_213501_initial_with_localization';
|
||||||
|
import * as migration_20251202_081830_add_is_super_admin_to_users from './20251202_081830_add_is_super_admin_to_users';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20251130_213501_initial_with_localization.up,
|
up: migration_20251130_213501_initial_with_localization.up,
|
||||||
down: migration_20251130_213501_initial_with_localization.down,
|
down: migration_20251130_213501_initial_with_localization.down,
|
||||||
name: '20251130_213501_initial_with_localization'
|
name: '20251130_213501_initial_with_localization',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20251202_081830_add_is_super_admin_to_users.up,
|
||||||
|
down: migration_20251202_081830_add_is_super_admin_to_users.down,
|
||||||
|
name: '20251202_081830_add_is_super_admin_to_users'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,8 @@ export default buildConfig({
|
||||||
'privacy-policy-settings': { customTenantField: true },
|
'privacy-policy-settings': { customTenantField: true },
|
||||||
} as Record<string, { customTenantField?: boolean }>),
|
} as Record<string, { customTenantField?: boolean }>),
|
||||||
},
|
},
|
||||||
|
// Super Admins haben Zugriff auf alle Tenants
|
||||||
|
userHasAccessToAllTenants: (user) => Boolean(user?.isSuperAdmin),
|
||||||
debug: true,
|
debug: true,
|
||||||
}),
|
}),
|
||||||
seoPlugin({
|
seoPlugin({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue