mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
- Add .claude/ configuration (agents, commands, hooks, get-shit-done workflows) - Add prompts/ directory with development planning documents - Add scripts/setup-tenants/ with tenant configuration - Add docs/screenshots/ - Remove obsolete phase2.2-corrections-report.md - Update pnpm-lock.yaml - Update detect-secrets.sh to ignore setup.sh (env var usage, not secrets) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
60 KiB
60 KiB
Phase 1: Community Core – Detailplan
Übersicht
Ziel: Community Management direkt im Hub mit YouTube API Integration und Vorbereitung für LinkedIn/Instagram/Facebook.
Voraussetzung: YouTube Operations Hub muss implementiert sein (YouTubeChannels, YouTubeContent Collections).
Kern-Features:
- Community Inbox (Unified View)
- YouTube Comments Sync (Direkte API)
- Response Templates mit Variablen
- Medical Flag System (CCS-spezifisch)
- AI-gestützte Sentiment-Analyse (Claude API)
- Multi-Platform Grundstruktur
Architektur Phase 1
┌─────────────────────────────────────────────────────────────────────────┐
│ COMMUNITY CORE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ COMMUNITY INBOX VIEW │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ YouTube │ │ LinkedIn │ │Instagram │ │ Facebook │ │ │
│ │ │ ✓ │ │ (prep) │ │ (prep) │ │ (prep) │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ INTEGRATION LAYER │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────┐ │ │
│ │ │ YouTube Data │ │ Claude AI │ │ Notification │ │ │
│ │ │ API v3 │ │ (Sentiment) │ │ Service │ │ │
│ │ └─────────────────┘ └─────────────────┘ └────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ Platform Adapters (Prepared for Phase 2) │ │ │
│ │ │ • YouTubeAdapter ✓ • LinkedInAdapter (stub) │ │ │
│ │ │ • InstagramAdapter (stub) • FacebookAdapter (stub) │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ PAYLOAD COLLECTIONS │ │
│ │ │ │
│ │ • social-platforms • community-interactions │ │
│ │ • social-accounts • community-templates │ │
│ │ • community-rules │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Teil 1: Access Control erweitern
1.0 Community Access Functions
// src/lib/communityAccess.ts
import type { Access } from 'payload'
interface UserWithRoles {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
youtubeRole?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager'
youtube_role?: 'none' | 'viewer' | 'editor' | 'producer' | 'creator' | 'manager'
communityRole?: 'none' | 'viewer' | 'moderator' | 'manager'
community_role?: 'none' | 'viewer' | 'moderator' | 'manager'
}
const checkIsSuperAdmin = (user: UserWithRoles | null): boolean => {
if (!user) return false
return Boolean(user.isSuperAdmin || user.is_super_admin)
}
const getCommunityRole = (user: UserWithRoles | null): string | undefined => {
if (!user) return undefined
return user.communityRole || user.community_role
}
const getYouTubeRole = (user: UserWithRoles | null): string | undefined => {
if (!user) return undefined
return user.youtubeRole || user.youtube_role
}
/**
* Prüft ob User Community-Manager oder Super-Admin ist
*/
export const isCommunityManager: Access = ({ req }) => {
const user = req.user as UserWithRoles | null
if (!user) return false
if (checkIsSuperAdmin(user)) return true
// YouTube-Manager haben auch Community-Zugriff
if (getYouTubeRole(user) === 'manager') return true
return getCommunityRole(user) === 'manager'
}
/**
* Prüft ob User mindestens Moderator-Rechte hat
*/
export const isCommunityModeratorOrAbove: Access = ({ req }) => {
const user = req.user as UserWithRoles | null
if (!user) return false
if (checkIsSuperAdmin(user)) return true
if (['manager', 'creator'].includes(getYouTubeRole(user) || '')) return true
return ['moderator', 'manager'].includes(getCommunityRole(user) || '')
}
/**
* Prüft ob User Zugriff auf Community-Features hat (mindestens Viewer)
*/
export const hasCommunityAccess: Access = ({ req }) => {
const user = req.user as UserWithRoles | null
if (!user) return false
if (checkIsSuperAdmin(user)) return true
// YouTube-Zugriff impliziert Community-Lesezugriff
const ytRole = getYouTubeRole(user)
if (ytRole && ytRole !== 'none') return true
const commRole = getCommunityRole(user)
return commRole !== 'none' && commRole !== undefined
}
/**
* Zugriff auf zugewiesene Interactions
*/
export const canAccessAssignedInteractions: Access = ({ req }) => {
const user = req.user as UserWithRoles | null
if (!user) return false
if (checkIsSuperAdmin(user)) return true
if (getYouTubeRole(user) === 'manager') return true
if (getCommunityRole(user) === 'manager') return true
// Für andere Rollen: Nur zugewiesene Interactions
return {
or: [
{ assignedTo: { equals: user.id } },
{ 'response.sentBy': { equals: user.id } },
],
}
}
Teil 2: Collections
2.1 Social Platforms (social-platforms)
// src/collections/SocialPlatforms.ts
import type { CollectionConfig } from 'payload'
import { isCommunityManager, hasCommunityAccess } from '../lib/communityAccess'
export const SocialPlatforms: CollectionConfig = {
slug: 'social-platforms',
labels: {
singular: 'Social Platform',
plural: 'Social Platforms',
},
admin: {
group: 'Community',
useAsTitle: 'name',
defaultColumns: ['name', 'slug', 'isActive', 'apiStatus'],
},
access: {
read: hasCommunityAccess,
create: isCommunityManager,
update: isCommunityManager,
delete: isCommunityManager,
},
fields: [
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
required: true,
label: 'Name',
admin: { width: '50%' },
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
label: 'Slug',
admin: { width: '50%' },
},
],
},
{
type: 'row',
fields: [
{
name: 'icon',
type: 'text',
label: 'Icon (Emoji)',
admin: {
width: '25%',
placeholder: '📺',
},
},
{
name: 'color',
type: 'text',
label: 'Brand Color',
admin: {
width: '25%',
placeholder: '#FF0000',
},
},
{
name: 'isActive',
type: 'checkbox',
label: 'Aktiv',
defaultValue: true,
admin: { width: '25%' },
},
{
name: 'apiStatus',
type: 'select',
label: 'API Status',
options: [
{ label: 'Verbunden', value: 'connected' },
{ label: 'Eingeschränkt', value: 'limited' },
{ label: 'Nicht verbunden', value: 'disconnected' },
{ label: 'In Entwicklung', value: 'development' },
],
defaultValue: 'disconnected',
admin: { width: '25%' },
},
],
},
// API Configuration
{
name: 'apiConfig',
type: 'group',
label: 'API Konfiguration',
admin: {
condition: (data) => data?.isActive,
},
fields: [
{
name: 'apiType',
type: 'select',
label: 'API Type',
options: [
{ label: 'YouTube Data API v3', value: 'youtube_v3' },
{ label: 'LinkedIn API', value: 'linkedin' },
{ label: 'Instagram Graph API', value: 'instagram_graph' },
{ label: 'Facebook Graph API', value: 'facebook_graph' },
{ label: 'Custom/Webhook', value: 'custom' },
],
},
{
name: 'baseUrl',
type: 'text',
label: 'Base URL',
admin: {
placeholder: 'https://www.googleapis.com/youtube/v3',
},
},
{
name: 'authType',
type: 'select',
label: 'Auth Type',
options: [
{ label: 'OAuth 2.0', value: 'oauth2' },
{ label: 'API Key', value: 'api_key' },
{ label: 'Bearer Token', value: 'bearer' },
],
},
{
name: 'scopes',
type: 'array',
label: 'OAuth Scopes',
admin: {
condition: (data, siblingData) => siblingData?.authType === 'oauth2',
},
fields: [
{ name: 'scope', type: 'text', label: 'Scope' },
],
},
],
},
// Interaction Types für diese Plattform
{
name: 'interactionTypes',
type: 'array',
label: 'Interaction Types',
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'text',
required: true,
label: 'Type',
admin: {
width: '30%',
placeholder: 'comment',
},
},
{
name: 'label',
type: 'text',
required: true,
label: 'Label',
admin: {
width: '30%',
placeholder: 'Kommentar',
},
},
{
name: 'icon',
type: 'text',
label: 'Icon',
admin: {
width: '20%',
placeholder: '💬',
},
},
{
name: 'canReply',
type: 'checkbox',
label: 'Reply möglich',
defaultValue: true,
admin: { width: '20%' },
},
],
},
],
},
// Rate Limits
{
name: 'rateLimits',
type: 'group',
label: 'Rate Limits',
fields: [
{
type: 'row',
fields: [
{
name: 'requestsPerMinute',
type: 'number',
label: 'Requests/Minute',
admin: { width: '33%' },
},
{
name: 'requestsPerDay',
type: 'number',
label: 'Requests/Tag',
admin: { width: '33%' },
},
{
name: 'quotaUnitsPerDay',
type: 'number',
label: 'Quota Units/Tag',
admin: {
width: '33%',
description: 'YouTube: 10.000/Tag',
},
},
],
},
],
},
],
timestamps: true,
}
2.2 Social Accounts (social-accounts)
// src/collections/SocialAccounts.ts
import type { CollectionConfig } from 'payload'
import { isCommunityManager, hasCommunityAccess } from '../lib/communityAccess'
export const SocialAccounts: CollectionConfig = {
slug: 'social-accounts',
labels: {
singular: 'Social Account',
plural: 'Social Accounts',
},
admin: {
group: 'Community',
useAsTitle: 'displayName',
defaultColumns: ['displayName', 'platform', 'linkedChannel', 'isActive'],
},
access: {
read: hasCommunityAccess,
create: isCommunityManager,
update: isCommunityManager,
delete: isCommunityManager,
},
fields: [
{
type: 'row',
fields: [
{
name: 'platform',
type: 'relationship',
relationTo: 'social-platforms',
required: true,
label: 'Plattform',
admin: { width: '50%' },
},
{
name: 'linkedChannel',
type: 'relationship',
relationTo: 'youtube-channels',
label: 'Verknüpfter YouTube-Kanal',
admin: {
width: '50%',
description: 'Für Zuordnung zu Brand/Kanal',
},
},
],
},
{
type: 'row',
fields: [
{
name: 'displayName',
type: 'text',
required: true,
label: 'Anzeigename',
admin: {
width: '50%',
placeholder: 'BlogWoman YouTube',
},
},
{
name: 'accountHandle',
type: 'text',
label: 'Handle / Username',
admin: {
width: '50%',
placeholder: '@blogwoman',
},
},
],
},
{
type: 'row',
fields: [
{
name: 'externalId',
type: 'text',
label: 'Platform Account ID',
admin: {
width: '50%',
description: 'YouTube Channel ID, LinkedIn URN, etc.',
},
},
{
name: 'accountUrl',
type: 'text',
label: 'Account URL',
admin: { width: '50%' },
},
],
},
{
name: 'isActive',
type: 'checkbox',
label: 'Aktiv',
defaultValue: true,
admin: {
position: 'sidebar',
},
},
// OAuth Credentials (verschlüsselt speichern!)
{
name: 'credentials',
type: 'group',
label: 'API Credentials',
admin: {
description: 'Sensible Daten – nur für Admins sichtbar',
condition: (data, siblingData, { user }) =>
Boolean((user as any)?.isSuperAdmin || (user as any)?.is_super_admin),
},
fields: [
{
name: 'accessToken',
type: 'text',
label: 'Access Token',
admin: {
description: 'OAuth Access Token',
},
},
{
name: 'refreshToken',
type: 'text',
label: 'Refresh Token',
},
{
name: 'tokenExpiresAt',
type: 'date',
label: 'Token Ablauf',
},
{
name: 'apiKey',
type: 'text',
label: 'API Key',
admin: {
description: 'Für API-Key basierte Auth',
},
},
],
},
// Stats (periodisch aktualisiert)
{
name: 'stats',
type: 'group',
label: 'Account Stats',
admin: {
position: 'sidebar',
},
fields: [
{
name: 'followers',
type: 'number',
label: 'Followers/Subscribers',
admin: { readOnly: true },
},
{
name: 'totalPosts',
type: 'number',
label: 'Total Posts/Videos',
admin: { readOnly: true },
},
{
name: 'lastSyncedAt',
type: 'date',
label: 'Letzter Sync',
admin: { readOnly: true },
},
],
},
// Sync Settings
{
name: 'syncSettings',
type: 'group',
label: 'Sync Settings',
fields: [
{
name: 'autoSyncEnabled',
type: 'checkbox',
label: 'Auto-Sync aktiviert',
defaultValue: true,
},
{
name: 'syncIntervalMinutes',
type: 'number',
label: 'Sync-Intervall (Minuten)',
defaultValue: 15,
min: 5,
max: 1440,
},
{
name: 'syncComments',
type: 'checkbox',
label: 'Kommentare synchronisieren',
defaultValue: true,
},
{
name: 'syncDMs',
type: 'checkbox',
label: 'DMs synchronisieren',
defaultValue: false,
admin: {
description: 'Nicht alle Plattformen unterstützen DM-API',
},
},
],
},
],
timestamps: true,
}
2.3 Community Interactions (community-interactions)
// src/collections/CommunityInteractions.ts
import type { CollectionConfig } from 'payload'
import {
hasCommunityAccess,
isCommunityModeratorOrAbove,
isCommunityManager,
canAccessAssignedInteractions
} from '../lib/communityAccess'
export const CommunityInteractions: CollectionConfig = {
slug: 'community-interactions',
labels: {
singular: 'Interaction',
plural: 'Interactions',
},
admin: {
group: 'Community',
defaultColumns: ['platform', 'type', 'authorName', 'status', 'priority', 'createdAt'],
listSearchableFields: ['author.name', 'author.handle', 'message'],
pagination: {
defaultLimit: 50,
},
},
access: {
read: canAccessAssignedInteractions,
create: isCommunityModeratorOrAbove,
update: canAccessAssignedInteractions,
delete: isCommunityManager,
},
fields: [
// === SOURCE ===
{
type: 'row',
fields: [
{
name: 'platform',
type: 'relationship',
relationTo: 'social-platforms',
required: true,
label: 'Plattform',
admin: { width: '33%' },
},
{
name: 'socialAccount',
type: 'relationship',
relationTo: 'social-accounts',
required: true,
label: 'Account',
admin: { width: '33%' },
},
{
name: 'linkedContent',
type: 'relationship',
relationTo: 'youtube-content',
label: 'Verknüpfter Content',
admin: { width: '33%' },
},
],
},
// === INTERACTION TYPE ===
{
type: 'row',
fields: [
{
name: 'type',
type: 'select',
required: true,
label: 'Typ',
options: [
{ label: 'Kommentar', value: 'comment' },
{ label: 'Antwort', value: 'reply' },
{ label: 'Direktnachricht', value: 'dm' },
{ label: 'Erwähnung', value: 'mention' },
{ label: 'Bewertung', value: 'review' },
{ label: 'Frage', value: 'question' },
],
admin: { width: '50%' },
},
{
name: 'externalId',
type: 'text',
label: 'External ID',
required: true,
unique: true,
index: true,
admin: {
width: '50%',
description: 'YouTube Comment ID, etc.',
},
},
],
},
// === PARENT (für Threads) ===
{
name: 'parentInteraction',
type: 'relationship',
relationTo: 'community-interactions',
label: 'Parent (bei Replies)',
admin: {
condition: (data) => data?.type === 'reply',
},
},
// === AUTHOR INFO ===
{
name: 'author',
type: 'group',
label: 'Author',
fields: [
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
label: 'Name',
admin: { width: '50%' },
},
{
name: 'handle',
type: 'text',
label: 'Handle',
admin: { width: '50%' },
},
],
},
{
type: 'row',
fields: [
{
name: 'profileUrl',
type: 'text',
label: 'Profile URL',
admin: { width: '50%' },
},
{
name: 'avatarUrl',
type: 'text',
label: 'Avatar URL',
admin: { width: '50%' },
},
],
},
{
type: 'row',
fields: [
{
name: 'isVerified',
type: 'checkbox',
label: 'Verifiziert',
admin: { width: '25%' },
},
{
name: 'isSubscriber',
type: 'checkbox',
label: 'Subscriber/Follower',
admin: { width: '25%' },
},
{
name: 'isMember',
type: 'checkbox',
label: 'Channel Member',
admin: { width: '25%' },
},
{
name: 'subscriberCount',
type: 'number',
label: 'Ihre Subscriber',
admin: { width: '25%' },
},
],
},
],
},
// === MESSAGE CONTENT ===
{
name: 'message',
type: 'textarea',
label: 'Nachricht',
required: true,
admin: {
rows: 4,
},
},
{
name: 'messageHtml',
type: 'textarea',
label: 'Original HTML',
admin: {
rows: 2,
description: 'Falls Plattform HTML liefert',
},
},
{
name: 'attachments',
type: 'array',
label: 'Attachments',
fields: [
{
type: 'row',
fields: [
{
name: 'type',
type: 'select',
label: 'Typ',
options: [
{ label: 'Bild', value: 'image' },
{ label: 'Video', value: 'video' },
{ label: 'Link', value: 'link' },
{ label: 'Sticker', value: 'sticker' },
],
admin: { width: '30%' },
},
{
name: 'url',
type: 'text',
label: 'URL',
admin: { width: '70%' },
},
],
},
],
},
{
name: 'publishedAt',
type: 'date',
label: 'Veröffentlicht am',
required: true,
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
// === AI ANALYSIS ===
{
name: 'analysis',
type: 'group',
label: 'AI Analyse',
admin: {
description: 'Automatisch via Claude API',
},
fields: [
{
type: 'row',
fields: [
{
name: 'sentiment',
type: 'select',
label: 'Sentiment',
options: [
{ label: 'Positiv', value: 'positive' },
{ label: 'Neutral', value: 'neutral' },
{ label: 'Negativ', value: 'negative' },
{ label: 'Frage', value: 'question' },
{ label: 'Dankbarkeit', value: 'gratitude' },
{ label: 'Frustration', value: 'frustration' },
],
admin: { width: '33%' },
},
{
name: 'sentimentScore',
type: 'number',
label: 'Score (-1 bis 1)',
min: -1,
max: 1,
admin: { width: '33%' },
},
{
name: 'confidence',
type: 'number',
label: 'Confidence %',
min: 0,
max: 100,
admin: { width: '33%' },
},
],
},
{
name: 'topics',
type: 'array',
label: 'Erkannte Themen',
fields: [
{
name: 'topic',
type: 'text',
label: 'Thema',
},
],
},
{
name: 'language',
type: 'text',
label: 'Sprache',
admin: {
placeholder: 'de',
},
},
{
name: 'suggestedTemplate',
type: 'relationship',
relationTo: 'community-templates',
label: 'Vorgeschlagenes Template',
},
{
name: 'suggestedReply',
type: 'textarea',
label: 'AI-generierter Antwortvorschlag',
},
{
name: 'analyzedAt',
type: 'date',
label: 'Analysiert am',
},
],
},
// === FLAGS ===
{
name: 'flags',
type: 'group',
label: 'Flags',
fields: [
{
type: 'row',
fields: [
{
name: 'isMedicalQuestion',
type: 'checkbox',
label: 'Medizinische Frage',
admin: {
width: '25%',
description: 'Erfordert ärztliche Review',
},
},
{
name: 'requiresEscalation',
type: 'checkbox',
label: 'Eskalation nötig',
admin: { width: '25%' },
},
{
name: 'isSpam',
type: 'checkbox',
label: 'Spam',
admin: { width: '25%' },
},
{
name: 'isFromInfluencer',
type: 'checkbox',
label: 'Influencer',
admin: {
width: '25%',
description: '>10k Follower',
},
},
],
},
],
},
// === WORKFLOW ===
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'new',
index: true,
label: 'Status',
options: [
{ label: 'Neu', value: 'new' },
{ label: 'In Review', value: 'in_review' },
{ label: 'Warten auf Info', value: 'waiting' },
{ label: 'Beantwortet', value: 'replied' },
{ label: 'Erledigt', value: 'resolved' },
{ label: 'Archiviert', value: 'archived' },
{ label: 'Spam', value: 'spam' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'priority',
type: 'select',
required: true,
defaultValue: 'normal',
index: true,
label: 'Priorität',
options: [
{ label: 'Urgent', value: 'urgent' },
{ label: 'Hoch', value: 'high' },
{ label: 'Normal', value: 'normal' },
{ label: 'Niedrig', value: 'low' },
],
admin: {
position: 'sidebar',
},
},
{
name: 'assignedTo',
type: 'relationship',
relationTo: 'users',
label: 'Zugewiesen an',
admin: {
position: 'sidebar',
},
},
{
name: 'responseDeadline',
type: 'date',
label: 'Antwort-Deadline',
admin: {
position: 'sidebar',
date: {
pickerAppearance: 'dayAndTime',
},
},
},
// === OUR RESPONSE ===
{
name: 'response',
type: 'group',
label: 'Unsere Antwort',
fields: [
{
name: 'text',
type: 'textarea',
label: 'Antwort-Text',
admin: { rows: 4 },
},
{
name: 'usedTemplate',
type: 'relationship',
relationTo: 'community-templates',
label: 'Verwendetes Template',
},
{
name: 'sentAt',
type: 'date',
label: 'Gesendet am',
},
{
name: 'sentBy',
type: 'relationship',
relationTo: 'users',
label: 'Gesendet von',
},
{
name: 'externalReplyId',
type: 'text',
label: 'Reply ID (extern)',
},
],
},
// === ENGAGEMENT (Platform-spezifisch) ===
{
name: 'engagement',
type: 'group',
label: 'Engagement',
admin: {
description: 'Wird beim Sync aktualisiert',
},
fields: [
{
type: 'row',
fields: [
{
name: 'likes',
type: 'number',
label: 'Likes',
admin: { width: '25%' },
},
{
name: 'replies',
type: 'number',
label: 'Replies',
admin: { width: '25%' },
},
{
name: 'isHearted',
type: 'checkbox',
label: 'Creator Heart',
admin: { width: '25%' },
},
{
name: 'isPinned',
type: 'checkbox',
label: 'Angepinnt',
admin: { width: '25%' },
},
],
},
],
},
// === INTERNAL NOTES ===
{
name: 'internalNotes',
type: 'textarea',
label: 'Interne Notizen',
admin: {
rows: 2,
description: 'Nur für Team sichtbar',
},
},
],
// === HOOKS ===
hooks: {
beforeChange: [
// Auto-set priority based on flags
async ({ data, operation }) => {
if (!data) return data
if (operation === 'create' || !data.priority) {
if (data?.flags?.isMedicalQuestion) {
data.priority = 'high'
}
if (data?.flags?.requiresEscalation) {
data.priority = 'urgent'
}
if (data?.flags?.isFromInfluencer) {
data.priority = data.priority === 'urgent' ? 'urgent' : 'high'
}
}
return data
},
],
afterChange: [
// Send notification for urgent items
async ({ doc, operation }) => {
if (operation === 'create' && doc.priority === 'urgent') {
// TODO: Notification Logic
console.log(`🚨 Urgent interaction: ${doc.id}`)
}
},
],
},
timestamps: true,
}
2.4 Community Templates (community-templates)
// src/collections/CommunityTemplates.ts
import type { CollectionConfig } from 'payload'
import { isCommunityManager, isCommunityModeratorOrAbove, hasCommunityAccess } from '../lib/communityAccess'
export const CommunityTemplates: CollectionConfig = {
slug: 'community-templates',
labels: {
singular: 'Response Template',
plural: 'Response Templates',
},
admin: {
group: 'Community',
useAsTitle: 'name',
defaultColumns: ['name', 'category', 'channel', 'usageCount'],
},
access: {
read: hasCommunityAccess,
create: isCommunityModeratorOrAbove,
update: isCommunityModeratorOrAbove,
delete: isCommunityManager,
},
fields: [
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
label: 'Name',
admin: { width: '50%' },
},
{
name: 'category',
type: 'select',
required: true,
label: 'Kategorie',
options: [
{ label: 'Danke', value: 'thank_you' },
{ label: 'Frage beantworten', value: 'question_answer' },
{ label: 'Hotline-Verweis', value: 'redirect_hotline' },
{ label: 'Medizinischer Disclaimer', value: 'medical_disclaimer' },
{ label: 'Produkt-Info', value: 'product_info' },
{ label: 'Content-Verweis', value: 'content_reference' },
{ label: 'Follow-up', value: 'follow_up' },
{ label: 'Negatives Feedback', value: 'negative_feedback' },
{ label: 'Spam-Antwort', value: 'spam_response' },
{ label: 'Begrüßung', value: 'welcome' },
],
admin: { width: '50%' },
},
],
},
{
type: 'row',
fields: [
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
label: 'Kanal (optional)',
admin: {
width: '50%',
description: 'Leer = für alle Kanäle',
},
},
{
name: 'platforms',
type: 'relationship',
relationTo: 'social-platforms',
hasMany: true,
label: 'Plattformen',
admin: {
width: '50%',
description: 'Leer = für alle Plattformen',
},
},
],
},
// Template Text mit Variablen
{
name: 'template',
type: 'textarea',
required: true,
localized: true,
label: 'Template Text',
admin: {
rows: 6,
description: 'Variablen: {{author_name}}, {{video_title}}, {{channel_name}}, {{hotline_number}}',
},
},
// Verfügbare Variablen
{
name: 'variables',
type: 'array',
label: 'Verfügbare Variablen',
admin: {
description: 'Dokumentation der Variablen in diesem Template',
},
fields: [
{
type: 'row',
fields: [
{
name: 'variable',
type: 'text',
required: true,
label: 'Variable',
admin: {
width: '30%',
placeholder: '{{author_name}}',
},
},
{
name: 'description',
type: 'text',
label: 'Beschreibung',
admin: {
width: '50%',
placeholder: 'Name des Kommentar-Autors',
},
},
{
name: 'defaultValue',
type: 'text',
label: 'Fallback',
admin: {
width: '20%',
},
},
],
},
],
},
// Auto-Suggest Keywords
{
name: 'autoSuggestKeywords',
type: 'array',
label: 'Auto-Suggest Keywords',
admin: {
description: 'Bei diesen Keywords wird das Template vorgeschlagen',
},
fields: [
{
name: 'keyword',
type: 'text',
required: true,
label: 'Keyword',
},
],
},
// Flags
{
type: 'row',
fields: [
{
name: 'requiresReview',
type: 'checkbox',
label: 'Review erforderlich',
admin: {
width: '33%',
description: 'Für medizinische Antworten',
},
},
{
name: 'isActive',
type: 'checkbox',
label: 'Aktiv',
defaultValue: true,
admin: { width: '33%' },
},
{
name: 'usageCount',
type: 'number',
label: 'Verwendungen',
defaultValue: 0,
admin: {
width: '33%',
readOnly: true,
},
},
],
},
// Beispiel-Output
{
name: 'exampleOutput',
type: 'textarea',
label: 'Beispiel-Output',
admin: {
rows: 3,
description: 'So sieht die Antwort mit ausgefüllten Variablen aus',
},
},
],
timestamps: true,
}
2.5 Community Rules (community-rules)
// src/collections/CommunityRules.ts
import type { CollectionConfig } from 'payload'
import { isCommunityManager, hasCommunityAccess } from '../lib/communityAccess'
export const CommunityRules: CollectionConfig = {
slug: 'community-rules',
labels: {
singular: 'Community Rule',
plural: 'Community Rules',
},
admin: {
group: 'Community',
useAsTitle: 'name',
defaultColumns: ['name', 'trigger.type', 'isActive', 'priority'],
},
access: {
read: hasCommunityAccess,
create: isCommunityManager,
update: isCommunityManager,
delete: isCommunityManager,
},
fields: [
{
type: 'row',
fields: [
{
name: 'name',
type: 'text',
required: true,
label: 'Name',
admin: { width: '50%' },
},
{
name: 'priority',
type: 'number',
required: true,
defaultValue: 100,
label: 'Priorität',
admin: {
width: '25%',
description: 'Niedrigere Zahl = höhere Priorität',
},
},
{
name: 'isActive',
type: 'checkbox',
label: 'Aktiv',
defaultValue: true,
admin: { width: '25%' },
},
],
},
{
name: 'description',
type: 'textarea',
label: 'Beschreibung',
admin: { rows: 2 },
},
// Scope
{
type: 'row',
fields: [
{
name: 'channel',
type: 'relationship',
relationTo: 'youtube-channels',
label: 'Kanal (optional)',
admin: {
width: '50%',
description: 'Leer = alle Kanäle',
},
},
{
name: 'platforms',
type: 'relationship',
relationTo: 'social-platforms',
hasMany: true,
label: 'Plattformen',
admin: {
width: '50%',
description: 'Leer = alle Plattformen',
},
},
],
},
// Trigger
{
name: 'trigger',
type: 'group',
label: 'Trigger',
fields: [
{
name: 'type',
type: 'select',
required: true,
label: 'Trigger-Typ',
options: [
{ label: 'Keyword Match', value: 'keyword' },
{ label: 'Sentiment', value: 'sentiment' },
{ label: 'Frage erkannt', value: 'question_detected' },
{ label: 'Medizinisch erkannt', value: 'medical_detected' },
{ label: 'Influencer', value: 'influencer' },
{ label: 'Alle neuen', value: 'all_new' },
{ label: 'Enthält Link', value: 'contains_link' },
{ label: 'Enthält Email', value: 'contains_email' },
],
},
{
name: 'keywords',
type: 'array',
label: 'Keywords',
admin: {
condition: (data, siblingData) => siblingData?.type === 'keyword',
},
fields: [
{
name: 'keyword',
type: 'text',
required: true,
label: 'Keyword',
},
{
name: 'matchType',
type: 'select',
label: 'Match-Typ',
options: [
{ label: 'Enthält', value: 'contains' },
{ label: 'Exakt', value: 'exact' },
{ label: 'Regex', value: 'regex' },
],
defaultValue: 'contains',
},
],
},
{
name: 'sentimentValues',
type: 'select',
hasMany: true,
label: 'Sentiment-Werte',
options: [
{ label: 'Positiv', value: 'positive' },
{ label: 'Negativ', value: 'negative' },
{ label: 'Neutral', value: 'neutral' },
{ label: 'Frage', value: 'question' },
],
admin: {
condition: (data, siblingData) => siblingData?.type === 'sentiment',
},
},
{
name: 'influencerMinFollowers',
type: 'number',
label: 'Min. Follower',
defaultValue: 10000,
admin: {
condition: (data, siblingData) => siblingData?.type === 'influencer',
},
},
],
},
// Actions
{
name: 'actions',
type: 'array',
label: 'Aktionen',
required: true,
minRows: 1,
fields: [
{
type: 'row',
fields: [
{
name: 'action',
type: 'select',
required: true,
label: 'Aktion',
options: [
{ label: 'Priorität setzen', value: 'set_priority' },
{ label: 'Zuweisen', value: 'assign_to' },
{ label: 'Flag setzen', value: 'set_flag' },
{ label: 'Template vorschlagen', value: 'suggest_template' },
{ label: 'Notification senden', value: 'send_notification' },
{ label: 'Medical Flag', value: 'flag_medical' },
{ label: 'Eskalieren', value: 'escalate' },
{ label: 'Als Spam markieren', value: 'mark_spam' },
{ label: 'Deadline setzen', value: 'set_deadline' },
],
admin: { width: '40%' },
},
{
name: 'value',
type: 'text',
label: 'Wert',
admin: {
width: '40%',
description: 'Priority: urgent/high/normal/low, Deadline: Stunden',
},
},
{
name: 'targetUser',
type: 'relationship',
relationTo: 'users',
label: 'User',
admin: {
width: '20%',
condition: (data, siblingData) =>
['assign_to', 'send_notification'].includes(siblingData?.action || ''),
},
},
],
},
{
name: 'targetTemplate',
type: 'relationship',
relationTo: 'community-templates',
label: 'Template',
admin: {
condition: (data, siblingData) => siblingData?.action === 'suggest_template',
},
},
],
},
// Stats
{
name: 'stats',
type: 'group',
label: 'Statistiken',
fields: [
{
type: 'row',
fields: [
{
name: 'timesTriggered',
type: 'number',
label: 'Ausgelöst',
defaultValue: 0,
admin: { width: '50%', readOnly: true },
},
{
name: 'lastTriggeredAt',
type: 'date',
label: 'Zuletzt ausgelöst',
admin: { width: '50%', readOnly: true },
},
],
},
],
},
],
timestamps: true,
}
Teil 3: Users Collection erweitern
3.1 Community-Rolle zu Users hinzufügen
// Ergänzung in src/collections/Users.ts (zu den bestehenden YouTube-Feldern)
{
name: 'communityRole',
type: 'select',
label: 'Community-Rolle',
defaultValue: 'none',
options: [
{ label: 'Keine', value: 'none' },
{ label: 'Viewer', value: 'viewer' },
{ label: 'Moderator', value: 'moderator' },
{ label: 'Manager', value: 'manager' },
],
admin: {
position: 'sidebar',
description: 'Zugriff auf Community Management Features',
},
},
Teil 4: YouTube API Integration
4.1 YouTube API Client
// src/lib/integrations/youtube/YouTubeClient.ts
import { google, youtube_v3 } from 'googleapis'
import type { Payload } from 'payload'
interface YouTubeCredentials {
clientId: string
clientSecret: string
accessToken: string
refreshToken: string
}
interface CommentThread {
id: string
snippet: {
videoId: string
topLevelComment: {
id: string
snippet: {
textDisplay: string
textOriginal: string
authorDisplayName: string
authorProfileImageUrl: string
authorChannelUrl: string
authorChannelId: { value: string }
likeCount: number
publishedAt: string
updatedAt: string
}
}
totalReplyCount: number
}
}
export class YouTubeClient {
private youtube: youtube_v3.Youtube
private oauth2Client: any
private payload: Payload
constructor(credentials: YouTubeCredentials, payload: Payload) {
this.payload = payload
this.oauth2Client = new google.auth.OAuth2(
credentials.clientId,
credentials.clientSecret,
process.env.YOUTUBE_REDIRECT_URI
)
this.oauth2Client.setCredentials({
access_token: credentials.accessToken,
refresh_token: credentials.refreshToken,
})
this.youtube = google.youtube({
version: 'v3',
auth: this.oauth2Client,
})
}
/**
* Alle Kommentar-Threads eines Videos abrufen
*/
async getVideoComments(
videoId: string,
pageToken?: string,
maxResults: number = 100
): Promise<{
comments: CommentThread[]
nextPageToken?: string
}> {
try {
const response = await this.youtube.commentThreads.list({
part: ['snippet', 'replies'],
videoId: videoId,
maxResults: maxResults,
pageToken: pageToken,
order: 'time',
textFormat: 'plainText',
})
return {
comments: response.data.items as CommentThread[],
nextPageToken: response.data.nextPageToken || undefined,
}
} catch (error) {
console.error('Error fetching comments:', error)
throw error
}
}
/**
* Alle Kommentare eines Kanals abrufen (alle Videos)
*/
async getChannelComments(
channelId: string,
publishedAfter?: Date,
maxResults: number = 100
): Promise<CommentThread[]> {
try {
const params: any = {
part: ['snippet', 'replies'],
allThreadsRelatedToChannelId: channelId,
maxResults: maxResults,
order: 'time',
textFormat: 'plainText',
}
if (publishedAfter) {
params.publishedAfter = publishedAfter.toISOString()
}
const response = await this.youtube.commentThreads.list(params)
return response.data.items as CommentThread[]
} catch (error) {
console.error('Error fetching channel comments:', error)
throw error
}
}
/**
* Auf einen Kommentar antworten
*/
async replyToComment(parentCommentId: string, text: string): Promise<string> {
try {
const response = await this.youtube.comments.insert({
part: ['snippet'],
requestBody: {
snippet: {
parentId: parentCommentId,
textOriginal: text,
},
},
})
return response.data.id!
} catch (error) {
console.error('Error replying to comment:', error)
throw error
}
}
/**
* Kommentar als Spam markieren
*/
async markAsSpam(commentId: string): Promise<void> {
try {
await this.youtube.comments.setModerationStatus({
id: [commentId],
moderationStatus: 'rejected',
})
} catch (error) {
console.error('Error marking as spam:', error)
throw error
}
}
/**
* Kommentar löschen
*/
async deleteComment(commentId: string): Promise<void> {
try {
await this.youtube.comments.delete({
id: commentId,
})
} catch (error) {
console.error('Error deleting comment:', error)
throw error
}
}
/**
* Access Token erneuern
*/
async refreshAccessToken(): Promise<{
accessToken: string
expiresAt: Date
}> {
try {
const { credentials } = await this.oauth2Client.refreshAccessToken()
return {
accessToken: credentials.access_token!,
expiresAt: new Date(credentials.expiry_date!),
}
} catch (error) {
console.error('Error refreshing token:', error)
throw error
}
}
}
4.2 Claude Analysis Service
// src/lib/integrations/claude/ClaudeAnalysisService.ts
import Anthropic from '@anthropic-ai/sdk'
interface CommentAnalysis {
sentiment: 'positive' | 'neutral' | 'negative' | 'question' | 'gratitude' | 'frustration'
sentimentScore: number
confidence: number
topics: string[]
language: string
isMedicalQuestion: boolean
requiresEscalation: boolean
isSpam: boolean
suggestedReply?: string
}
export class ClaudeAnalysisService {
private client: Anthropic
constructor() {
this.client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
})
}
/**
* Kommentar analysieren
*/
async analyzeComment(message: string): Promise<CommentAnalysis> {
const systemPrompt = `Du bist ein Analyse-Assistent für YouTube-Kommentare eines deutschen Healthcare-Unternehmens (Medizinische Zweitmeinung) und eines Lifestyle-Kanals.
Analysiere den Kommentar und gib ein JSON-Objekt zurück mit:
- sentiment: "positive", "neutral", "negative", "question", "gratitude", "frustration"
- sentimentScore: Zahl von -1 (sehr negativ) bis 1 (sehr positiv)
- confidence: Konfidenz der Analyse 0-100
- topics: Array von erkannten Themen (max 3)
- language: ISO-639-1 Sprachcode (z.B. "de", "en")
- isMedicalQuestion: true wenn es um medizinische Fragen/Gesundheit geht
- requiresEscalation: true wenn dringend/kritisch/negativ oder Beschwerden
- isSpam: true wenn Spam/Werbung/Bot
- suggestedReply: Kurzer Antwortvorschlag auf Deutsch (optional, nur wenn sinnvoll)
WICHTIG für isMedicalQuestion:
- Fragen zu Intensivmedizin, Diagnosen, Behandlungen, Medikamenten = true
- Fragen zu Angehörigen von Patienten = true
- Allgemeine Lifestyle-Fragen (Mode, Zeitmanagement) = false
Antworte NUR mit dem JSON-Objekt, kein anderer Text.`
try {
const response = await this.client.messages.create({
model: 'claude-3-haiku-20240307',
max_tokens: 500,
system: systemPrompt,
messages: [
{
role: 'user',
content: `Analysiere diesen Kommentar:\n\n"${message}"`,
},
],
})
const content = response.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type')
}
const analysis = JSON.parse(content.text) as CommentAnalysis
return analysis
} catch (error) {
console.error('Claude analysis error:', error)
// Fallback bei Fehler
return {
sentiment: 'neutral',
sentimentScore: 0,
confidence: 0,
topics: [],
language: 'de',
isMedicalQuestion: false,
requiresEscalation: false,
isSpam: false,
}
}
}
/**
* Antwort-Vorschlag generieren
*/
async generateReply(
comment: string,
context: {
videoTitle: string
channelName: string
isBusinessChannel: boolean
template?: string
}
): Promise<string> {
const systemPrompt = `Du bist ein Community-Manager für ${context.channelName}.
${context.isBusinessChannel
? 'Dies ist ein Healthcare-Kanal für medizinische Zweitmeinungen. Antworten müssen professionell sein und dürfen keine medizinischen Ratschläge geben. Bei medizinischen Fragen immer auf die Hotline verweisen.'
: 'Dies ist ein Lifestyle-Kanal für berufstätige Mütter. Antworten sollten warm, persönlich und hilfreich sein.'
}
Erstelle eine passende, kurze Antwort auf den Kommentar.
- Maximal 2-3 Sätze
- Persönlich und authentisch
- Auf Deutsch
${context.template ? `\nVerwende dieses Template als Basis:\n${context.template}` : ''}
Antworte NUR mit dem Antworttext, kein anderer Text.`
try {
const response = await this.client.messages.create({
model: 'claude-3-haiku-20240307',
max_tokens: 200,
system: systemPrompt,
messages: [
{
role: 'user',
content: `Video: "${context.videoTitle}"\n\nKommentar: "${comment}"\n\nErstelle eine Antwort:`,
},
],
})
const content = response.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type')
}
return content.text.trim()
} catch (error) {
console.error('Claude reply generation error:', error)
throw error
}
}
}
Teil 5: API Endpoints
5.1 Comments Sync Endpoint
// src/app/(payload)/api/community/sync-comments/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { CommentsSyncService } from '@/lib/integrations/youtube/CommentsSyncService'
export async function POST(req: NextRequest) {
try {
const payload = await getPayload({ config })
const body = await req.json()
const { socialAccountId, sinceDate, maxComments, analyzeWithAI } = body
if (!socialAccountId) {
return NextResponse.json(
{ error: 'socialAccountId required' },
{ status: 400 }
)
}
const syncService = new CommentsSyncService(payload)
const result = await syncService.syncComments({
socialAccountId,
sinceDate: sinceDate ? new Date(sinceDate) : undefined,
maxComments: maxComments || 100,
analyzeWithAI: analyzeWithAI ?? true,
})
return NextResponse.json(result)
} catch (error: any) {
console.error('Sync error:', error)
return NextResponse.json(
{ error: error.message },
{ status: 500 }
)
}
}
5.2 Reply Endpoint
// src/app/(payload)/api/community/reply/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { YouTubeClient } from '@/lib/integrations/youtube/YouTubeClient'
export async function POST(req: NextRequest) {
try {
const payload = await getPayload({ config })
const body = await req.json()
const { interactionId, replyText, templateId } = body
if (!interactionId || !replyText) {
return NextResponse.json(
{ error: 'interactionId and replyText required' },
{ status: 400 }
)
}
// 1. Interaction laden
const interaction = await payload.findByID({
collection: 'community-interactions',
id: interactionId,
depth: 2,
})
if (!interaction) {
return NextResponse.json(
{ error: 'Interaction not found' },
{ status: 404 }
)
}
// 2. YouTube Client initialisieren
const account = interaction.socialAccount as any
const youtubeClient = new YouTubeClient({
clientId: process.env.YOUTUBE_CLIENT_ID!,
clientSecret: process.env.YOUTUBE_CLIENT_SECRET!,
accessToken: account.credentials.accessToken,
refreshToken: account.credentials.refreshToken,
}, payload)
// 3. Reply senden
const replyId = await youtubeClient.replyToComment(
interaction.externalId,
replyText
)
// 4. Interaction aktualisieren
await payload.update({
collection: 'community-interactions',
id: interactionId,
data: {
status: 'replied',
response: {
text: replyText,
usedTemplate: templateId || null,
sentAt: new Date().toISOString(),
externalReplyId: replyId,
},
},
})
// 5. Template Usage Counter erhöhen
if (templateId) {
const template = await payload.findByID({
collection: 'community-templates',
id: templateId,
})
await payload.update({
collection: 'community-templates',
id: templateId,
data: {
usageCount: ((template as any).usageCount || 0) + 1,
},
})
}
return NextResponse.json({
success: true,
replyId,
})
} catch (error: any) {
console.error('Reply error:', error)
return NextResponse.json(
{ error: error.message },
{ status: 500 }
)
}
}
Teil 6: Environment Variables
# .env - Neue Variablen für Community Phase 1
# YouTube API (Google Cloud Console)
YOUTUBE_CLIENT_ID=your-client-id.apps.googleusercontent.com
YOUTUBE_CLIENT_SECRET=your-client-secret
YOUTUBE_REDIRECT_URI=https://pl.porwoll.tech/api/youtube/callback
# Claude API (Anthropic) - bereits vorhanden für andere Features
ANTHROPIC_API_KEY=sk-ant-api03-...
# Optional: Für spätere Phasen vorbereitet
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
INSTAGRAM_ACCESS_TOKEN=
FACEBOOK_ACCESS_TOKEN=
Teil 7: payload.config.ts Ergänzungen
// payload.config.ts - Ergänzungen für Community Phase 1
import { SocialPlatforms } from './collections/SocialPlatforms'
import { SocialAccounts } from './collections/SocialAccounts'
import { CommunityInteractions } from './collections/CommunityInteractions'
import { CommunityTemplates } from './collections/CommunityTemplates'
import { CommunityRules } from './collections/CommunityRules'
export default buildConfig({
// ...
collections: [
// Existing YouTube Collections
YouTubeChannels,
YouTubeContent,
YtSeries,
YtTasks,
YtNotifications,
YtBatches,
YtMonthlyGoals,
YtScriptTemplates,
YtChecklistTemplates,
// NEW: Community Collections
SocialPlatforms,
SocialAccounts,
CommunityInteractions,
CommunityTemplates,
CommunityRules,
],
// ...
})
Teil 8: Migration
Die Migration muss folgende Tabellen erstellen:
social_platforms+social_platforms_interaction_types+social_platforms_api_config_scopessocial_accountscommunity_interactions+community_interactions_attachments+community_interactions_analysis_topicscommunity_templates+community_templates_locales+community_templates_variables+community_templates_auto_suggest_keywordscommunity_rules+community_rules_trigger_keywords+community_rules_actionspayload_locked_documents_relserweitern um alle neuen Collection-IDs
WICHTIG: Siehe CLAUDE.md Abschnitt "KRITISCH: Neue Collections hinzufügen" für das korrekte Migrations-Muster.
Zusammenfassung
Neue Collections (5)
social-platforms– Plattform-Definitionensocial-accounts– Account-Verknüpfungencommunity-interactions– Alle Kommentare/DMscommunity-templates– Antwort-Vorlagencommunity-rules– Auto-Regeln
Neue Lib-Dateien (3)
src/lib/communityAccess.ts– Access Controlsrc/lib/integrations/youtube/YouTubeClient.ts– YouTube APIsrc/lib/integrations/claude/ClaudeAnalysisService.ts– AI Analyse
API Endpoints (2)
POST /api/community/sync-comments– Manueller SyncPOST /api/community/reply– Antwort senden
User-Feld (1)
communityRolein Users Collection
Abhängigkeiten zu bestehendem Code
youtube-channelsCollection (linkedChannel Referenz)youtube-contentCollection (linkedContent Referenz)usersCollection (assignedTo, sentBy Referenzen)- Bestehende YouTube-Access-Rollen werden berücksichtigt