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:
Martin Porwoll 2025-12-05 14:26:08 +00:00
parent d18e58de40
commit dbe36ad381
8 changed files with 14696 additions and 10 deletions

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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";`)
}

View file

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

View file

@ -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({