diff --git a/directus-cms/.env.example b/directus-cms/.env.example index 1112211..5f3df80 100644 --- a/directus-cms/.env.example +++ b/directus-cms/.env.example @@ -151,4 +151,30 @@ ALGOLIA_INDEX="programmierbar_website_dev" #################################################################################################### ## Vercel -VERCEL_DEPLOY_WEBHOOK_URL="" \ No newline at end of file +VERCEL_DEPLOY_WEBHOOK_URL="" + +#################################################################################################### +## Gemini (Content Generation) + +GEMINI_API_KEY="" + +#################################################################################################### +## Social Media Publishing + +# Bluesky (AT Protocol) +BLUESKY_HANDLE="" +BLUESKY_APP_PASSWORD="" + +# Mastodon +MASTODON_INSTANCE_URL="" +MASTODON_ACCESS_TOKEN="" + +# LinkedIn (OAuth 2.0) +LINKEDIN_CLIENT_ID="" +LINKEDIN_CLIENT_SECRET="" +LINKEDIN_ACCESS_TOKEN="" +LINKEDIN_COMPANY_ID="" + +# Instagram (via Facebook Graph API) +INSTAGRAM_BUSINESS_ACCOUNT_ID="" +FACEBOOK_ACCESS_TOKEN="" \ No newline at end of file diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json b/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json index 1b39eb8..0b79163 100644 --- a/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/package.json @@ -69,6 +69,26 @@ "name": "algolia-index", "source": "src/algolia-index/index.ts" }, + { + "type": "hook", + "name": "member-matching", + "source": "src/member-matching/index.ts" + }, + { + "type": "hook", + "name": "content-generation", + "source": "src/content-generation/index.ts" + }, + { + "type": "hook", + "name": "content-approval", + "source": "src/content-approval/index.ts" + }, + { + "type": "hook", + "name": "social-media-publish", + "source": "src/social-media-publish/index.ts" + }, { "type": "endpoint", "name": "conference", diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-approval/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-approval/index.ts new file mode 100644 index 0000000..c9105d7 --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-approval/index.ts @@ -0,0 +1,82 @@ +import { defineHook } from '@directus/extensions-sdk' + +const HOOK_NAME = 'content-approval' + +export default defineHook(({ action }, hookContext) => { + const logger = hookContext.logger + const ItemsService = hookContext.services.ItemsService + const getSchema = hookContext.getSchema + + // Listen for updates to podcast_generated_content + action('podcast_generated_content.items.update', async function (metadata, eventContext) { + const { payload, keys } = metadata + + // Only proceed if status is being set to 'approved' + if (payload.status !== 'approved') { + return + } + + try { + const schema = await getSchema() + + const generatedContentService = new ItemsService('podcast_generated_content', { + schema, + accountability: eventContext.accountability, + }) + + const podcastsService = new ItemsService('podcasts', { + schema, + accountability: eventContext.accountability, + }) + + // Process each approved content item + for (const contentId of keys) { + const content = await generatedContentService.readOne(contentId, { + fields: ['id', 'podcast_id', 'content_type', 'generated_text'], + }) + + if (!content || !content.podcast_id) { + continue + } + + // If this is shownotes, copy to podcast description + if (content.content_type === 'shownotes') { + if (content.generated_text) { + logger.info( + `${HOOK_NAME}: Copying approved shownotes to podcast ${content.podcast_id} description` + ) + + await podcastsService.updateOne(content.podcast_id, { + description: content.generated_text, + }) + } + } + + // Check if all content for this podcast is approved + const allContent = await generatedContentService.readByQuery({ + filter: { + podcast_id: { _eq: content.podcast_id }, + }, + fields: ['id', 'status'], + }) + + const allApproved = + allContent.length > 0 && allContent.every((c: any) => c.status === 'approved') + + if (allApproved) { + logger.info( + `${HOOK_NAME}: All content approved for podcast ${content.podcast_id}, updating status to 'approved'` + ) + + await podcastsService.updateOne(content.podcast_id, { + publishing_status: 'approved', + }) + } + } + } catch (err: any) { + logger.error(`${HOOK_NAME}: Error processing content approval: ${err?.message || err}`) + } + }) + + logger.info(`${HOOK_NAME} hook registered`) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-generation/generateContent.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-generation/generateContent.ts new file mode 100644 index 0000000..79c55a7 --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-generation/generateContent.ts @@ -0,0 +1,400 @@ +import type { Logger } from 'pino' + +interface HookServices { + logger: Logger + ItemsService: any + getSchema: () => Promise + env: Record + eventContext: any +} + +interface PodcastData { + id: number + title: string + type: string + number?: string + transcript_text?: string + speakers?: Array<{ speaker: { first_name: string; last_name: string; occupation?: string; description?: string } }> + members?: Array<{ member: { first_name: string; last_name: string } }> +} + +const WORD_COUNT_TARGETS: Record = { + deep_dive: '300-500', + cto_special: '200-350', + news: '100-200', + other: '150-400', +} + +function buildShownotesPrompt(podcast: PodcastData): string { + const episodeType = podcast.type || 'other' + const wordCount = WORD_COUNT_TARGETS[episodeType] || '150-400' + + const hosts = podcast.members?.map((m) => `${m.member?.first_name} ${m.member?.last_name}`.trim()).filter(Boolean).join(', ') || 'programmier.bar Team' + + const guests = + podcast.speakers?.map((s) => `${s.speaker?.first_name} ${s.speaker?.last_name}`).filter(Boolean).join(', ') || '' + + const guestInfo = + podcast.speakers + ?.map((s) => { + const speaker = s.speaker + if (!speaker) return '' + return `${speaker.first_name} ${speaker.last_name}${speaker.occupation ? ` - ${speaker.occupation}` : ''}${speaker.description ? `: ${speaker.description}` : ''}` + }) + .filter(Boolean) + .join('\n') || '' + + return `Erstelle Shownotes für folgende Podcast-Episode: + +**Episode-Typ:** ${episodeType} +**Titel:** ${podcast.title} +**Episodennummer:** ${podcast.number || 'N/A'} + +**Hosts:** ${hosts} +**Gäste:** ${guests || 'Keine Gäste'} +${guestInfo ? `**Gast-Info:** ${guestInfo}` : ''} + +**Transkript:** +${podcast.transcript_text || 'Kein Transkript verfügbar'} + +--- + +Erstelle basierend auf dem Transkript: + +1. **Beschreibung** (${wordCount} Wörter): Eine einladende Episode-Beschreibung im programmier.bar Stil + +2. **Themenübersicht**: 3-7 Hauptthemen als Bullet Points + +3. **Timestamps**: Wichtige Zeitmarken für Themenwechsel (Format: MM:SS - Thema) + +4. **Ressourcen**: Liste der im Gespräch erwähnten Tools, Technologien, Links + +Formatiere die Beschreibung in HTML mit ,
    ,
  • , und Tags wo angemessen. + +Antworte im folgenden JSON-Format: +{ + "description": "HTML-formatierte Beschreibung", + "topics": ["Thema 1", "Thema 2", ...], + "timestamps": [{"time": "00:00", "topic": "Intro"}, ...], + "resources": [{"name": "Resource Name", "url": "https://..."}, ...] +}` +} + +function buildSocialPrompt( + platform: 'linkedin' | 'instagram' | 'bluesky' | 'mastodon', + podcast: PodcastData, + topics: string[] +): string { + const guests = + podcast.speakers?.map((s) => `${s.speaker?.first_name} ${s.speaker?.last_name}`).filter(Boolean).join(', ') || '' + + const guestCompanies = + podcast.speakers + ?.map((s) => s.speaker?.occupation?.split(' at ')[1] || s.speaker?.occupation) + .filter(Boolean) + .join(', ') || '' + + const topicsText = topics.map((t) => `- ${t}`).join('\n') + + const platformPrompts: Record = { + linkedin: `Erstelle einen LinkedIn-Post für diese Podcast-Episode: + +**Titel:** ${podcast.title} +**Typ:** ${podcast.type} +**Gäste:** ${guests || 'Keine Gäste'} +**Unternehmen:** ${guestCompanies || 'N/A'} + +**Key Topics:** +${topicsText} + +--- + +Erstelle einen LinkedIn-Post mit: +1. Hook (erste 2 Zeilen sind am wichtigsten - vor "mehr anzeigen") +2. 2-3 Key Takeaways oder interessante Punkte +3. Call-to-Action mit Link-Platzhalter [LINK] +4. 3-5 relevante Hashtags + +Gib auch an, welche Personen/Unternehmen getaggt werden sollten. + +Antworte im JSON-Format: +{ + "post_text": "Der vollständige Post-Text", + "hashtags": ["#tag1", "#tag2"], + "tagging_suggestions": ["@Person1", "@Company1"] +}`, + + instagram: `Erstelle eine Instagram-Caption für diese Podcast-Episode: + +**Titel:** ${podcast.title} +**Typ:** ${podcast.type} +**Gäste:** ${guests || 'Keine Gäste'} +**Key Topics:** +${topicsText} + +--- + +Erstelle eine Instagram-Caption mit: +1. Aufmerksamkeitsstarke erste Zeile +2. 2-3 Sätze zum Inhalt +3. Call-to-Action ("Link in Bio") +4. 10-15 relevante Hashtags (Mix aus großen und Nischen-Tags) + +Antworte im JSON-Format: +{ + "post_text": "Caption ohne Hashtags", + "hashtags": ["#tag1", "#tag2", ...] +}`, + + bluesky: `Erstelle einen Bluesky-Post für diese Podcast-Episode: + +**Titel:** ${podcast.title} +**Gäste:** ${guests || 'Keine Gäste'} + +--- + +Erstelle einen Bluesky-Post (max 300 Zeichen inkl. Link-Platzhalter [LINK]) mit: +1. Hook oder interessantes Zitat +2. Kurze Info zur Episode +3. Platz für Link + +Antworte im JSON-Format: +{ + "post_text": "Der vollständige Post-Text (max 300 Zeichen)" +}`, + + mastodon: `Erstelle einen Mastodon-Post für diese Podcast-Episode: + +**Titel:** ${podcast.title} +**Gäste:** ${guests || 'Keine Gäste'} +**Topics:** +${topicsText} + +--- + +Erstelle einen Mastodon-Post (max 500 Zeichen) mit: +1. Beschreibung der Episode +2. Was Hörer:innen lernen können +3. Link-Platzhalter [LINK] +4. 3-5 Hashtags + +Antworte im JSON-Format: +{ + "post_text": "Der vollständige Post-Text", + "hashtags": ["#tag1", "#tag2"] +}`, + } + + return platformPrompts[platform] +} + +async function callGemini(apiKey: string, systemPrompt: string, userPrompt: string): Promise { + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent?key=${apiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [ + { + parts: [{ text: `${systemPrompt}\n\n${userPrompt}` }], + }, + ], + generationConfig: { + temperature: 0.7, + topK: 40, + topP: 0.95, + maxOutputTokens: 8192, + }, + }), + } + ) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Gemini API error: ${response.status} - ${error}`) + } + + const data = await response.json() + const text = data.candidates?.[0]?.content?.parts?.[0]?.text + + if (!text) { + throw new Error('No content in Gemini response') + } + + return text +} + +function extractJson(text: string): any { + // Try to extract JSON from the response (may be wrapped in markdown code blocks) + const jsonMatch = text.match(/```json\n?([\s\S]*?)\n?```/) || text.match(/\{[\s\S]*\}/) + if (jsonMatch) { + const jsonStr = jsonMatch[1] || jsonMatch[0] + return JSON.parse(jsonStr) + } + throw new Error('Could not extract JSON from response') +} + +export async function generateContent(hookName: string, podcastId: number, services: HookServices): Promise { + const { logger, ItemsService, getSchema, env, eventContext } = services + const geminiApiKey = env.GEMINI_API_KEY + + try { + const schema = await getSchema() + + // Create services + const podcastsService = new ItemsService('podcasts', { + schema, + accountability: eventContext.accountability, + }) + + const generatedContentService = new ItemsService('podcast_generated_content', { + schema, + accountability: eventContext.accountability, + }) + + // Fetch podcast with related data + const podcast: PodcastData = await podcastsService.readOne(podcastId, { + fields: [ + 'id', + 'title', + 'type', + 'number', + 'transcript_text', + 'speakers.speaker.first_name', + 'speakers.speaker.last_name', + 'speakers.speaker.occupation', + 'speakers.speaker.description', + 'members.member.first_name', + 'members.member.last_name', + ], + }) + + if (!podcast.transcript_text) { + logger.warn(`${hookName}: Podcast ${podcastId} has no transcript, skipping content generation`) + return + } + + logger.info(`${hookName}: Generating content for podcast: ${podcast.title}`) + + // Update status to transcript_ready + logger.info(`${hookName}: Updating status to transcript_ready`) + await podcastsService.updateOne(podcastId, { + publishing_status: 'transcript_ready', + }) + + logger.info(`${hookName}: Calling Gemini API for shownotes`) + // System prompt for shownotes + const shownotesSystemPrompt = `Du bist ein erfahrener Content-Redakteur für den deutschen Entwickler-Podcast "programmier.bar". +Deine Aufgabe ist es, ansprechende Shownotes für Podcast-Episoden zu erstellen, die sowohl informativ als auch einladend sind. + +Stilrichtlinien: +- Deutsch als Hauptsprache +- Technische Fachbegriffe auf Englisch belassen (z.B. "TypeScript", "Machine Learning", "API") +- Freundlich, professionell, aber nicht steif +- HTML-Formatierung: , ,
      ,
        ,
      1. , +- Bullet Points für Themenübersicht verwenden` + + // Generate shownotes + const shownotesPrompt = buildShownotesPrompt(podcast) + logger.info(`${hookName}: Sending request to Gemini API`) + const shownotesResponse = await callGemini(geminiApiKey, shownotesSystemPrompt, shownotesPrompt) + logger.info(`${hookName}: Received response from Gemini API`) + const shownotesData = extractJson(shownotesResponse) + + logger.info(`${hookName}: Shownotes generated for podcast ${podcastId}`) + + // Store shownotes in podcast_generated_content + // Format: description followed by topics, timestamps, and resources + let shownotesText = shownotesData.description || '' + + if (shownotesData.topics && shownotesData.topics.length > 0) { + shownotesText += '\n\nThemen:\n
          \n' + shownotesText += shownotesData.topics.map((t: string) => `
        • ${t}
        • `).join('\n') + shownotesText += '\n
        ' + } + + if (shownotesData.timestamps && shownotesData.timestamps.length > 0) { + shownotesText += '\n\nTimestamps:\n
          \n' + shownotesText += shownotesData.timestamps.map((ts: { time: string; topic: string }) => `
        • ${ts.time} - ${ts.topic}
        • `).join('\n') + shownotesText += '\n
        ' + } + + if (shownotesData.resources && shownotesData.resources.length > 0) { + shownotesText += '\n\nRessourcen:\n
        ' + } + + await generatedContentService.createOne({ + podcast_id: podcast.id, + content_type: 'shownotes', + generated_text: shownotesText, + status: 'generated', + generated_at: new Date().toISOString(), + llm_model: 'gemini-3-flash-preview', + prompt_version: '1.0', + }) + + // Generate social media posts + const platforms: Array<'linkedin' | 'instagram' | 'bluesky' | 'mastodon'> = [ + 'linkedin', + 'instagram', + 'bluesky', + 'mastodon', + ] + + const socialSystemPrompts: Record = { + linkedin: `Du erstellst professionelle LinkedIn-Posts für den Podcast "programmier.bar". Der Ton ist professionell aber nahbar, fachlich fundiert aber zugänglich.`, + instagram: `Du erstellst Instagram-Posts für "programmier.bar". Instagram ist visuell-fokussiert, der Text ist die Caption. Emoji sind erlaubt und erwünscht.`, + bluesky: `Du erstellst Posts für Bluesky für "programmier.bar". Bluesky ist ähnlich wie Twitter, mit 300 Zeichen Limit pro Post. Kurz und prägnant.`, + mastodon: `Du erstellst Posts für Mastodon für "programmier.bar". Mastodon hat ein 500 Zeichen Limit und eine tech-affine, Community-orientierte Nutzerschaft.`, + } + + for (const platform of platforms) { + try { + const socialPrompt = buildSocialPrompt(platform, podcast, shownotesData.topics || []) + const socialResponse = await callGemini(geminiApiKey, socialSystemPrompts[platform], socialPrompt) + const socialData = extractJson(socialResponse) + + // Format social post: text + hashtags (if available) + let socialText = socialData.post_text || '' + if (socialData.hashtags && socialData.hashtags.length > 0) { + socialText += '\n\n' + socialData.hashtags.join(' ') + } + if (socialData.tagging_suggestions && socialData.tagging_suggestions.length > 0) { + socialText += '\n\nTagging: ' + socialData.tagging_suggestions.join(', ') + } + + await generatedContentService.createOne({ + podcast_id: podcast.id, + content_type: `social_${platform}`, + generated_text: socialText, + status: 'generated', + generated_at: new Date().toISOString(), + llm_model: 'gemini-3-flash-preview', + prompt_version: '1.0', + }) + + logger.info(`${hookName}: ${platform} post generated for podcast ${podcastId}`) + } catch (err) { + logger.error(`${hookName}: Failed to generate ${platform} post for podcast ${podcastId}:`, err) + } + } + + // Update podcast status to content_review + await podcastsService.updateOne(podcastId, { + publishing_status: 'content_review', + }) + + logger.info(`${hookName}: Content generation complete for podcast ${podcastId}`) + } catch (err: any) { + logger.error(`${hookName}: Content generation error for podcast ${podcastId}: ${err?.message || err}`) + if (err?.stack) { + logger.error(`${hookName}: Stack trace: ${err.stack}`) + } + throw err + } +} diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-generation/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-generation/index.ts new file mode 100644 index 0000000..8d5f3f3 --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/content-generation/index.ts @@ -0,0 +1,42 @@ +import { defineHook } from '@directus/extensions-sdk' +import { generateContent } from './generateContent.js' + +const HOOK_NAME = 'content-generation' + +export default defineHook(({ action }, hookContext) => { + const logger = hookContext.logger + const env = hookContext.env + const ItemsService = hookContext.services.ItemsService + const getSchema = hookContext.getSchema + + if (!env.GEMINI_API_KEY) { + logger.warn(`${HOOK_NAME} hook: GEMINI_API_KEY not set. Content generation will not be active.`) + return + } + + // Trigger on podcast update when transcript_text is added + action('podcasts.items.update', async function (metadata, eventContext) { + const { payload, keys } = metadata + + // Check if transcript_text was updated + const hasTranscriptText = payload.transcript_text && payload.transcript_text.trim().length > 0 + + if (!hasTranscriptText) { + return + } + + const podcastId = keys[0] + logger.info(`${HOOK_NAME} hook: Transcript detected for podcast ${podcastId}, triggering content generation`) + + // Run content generation asynchronously (don't block the update) + generateContent(HOOK_NAME, podcastId, { + logger, + ItemsService, + getSchema, + env, + eventContext, + }).catch((err) => { + logger.error(`${HOOK_NAME} hook: Content generation failed for podcast ${podcastId}:`, err) + }) + }) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/__tests__/matchMembers.test.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/__tests__/matchMembers.test.ts new file mode 100644 index 0000000..9ed64ad --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/__tests__/matchMembers.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from '@jest/globals' +import { extractSpeakerNames, findMatchingMembers, MemberData } from './../matchMembers.ts' + +describe('extractSpeakerNames', () => { + test('should extract names with timestamp format', () => { + const transcript = `Jan Gregor Emge-Triebel (00:12.534) + +Hallo und herzlich willkommen bei einer neuen News-Ausgabe. + +Fabi Fink (00:35.735) +Ja, hast du` + + const result = extractSpeakerNames(transcript) + expect(result).toContain('Jan Gregor Emge-Triebel') + expect(result).toContain('Fabi Fink') + expect(result).toHaveLength(2) + }) + + test('should extract simple speaker names with colon', () => { + const transcript = `Dennis: Hello everyone, welcome to the show. +Jojo: Thanks Dennis, great to be here. +Fabi: Let's get started!` + + const result = extractSpeakerNames(transcript) + expect(result).toEqual(['Dennis', 'Jojo', 'Fabi']) + }) + + test('should extract full names with first and last name (colon format)', () => { + const transcript = `Dennis Becker: Welcome to the podcast. +Jojo Schweizer: Today we talk about TypeScript.` + + const result = extractSpeakerNames(transcript) + expect(result).toEqual(['Dennis Becker', 'Jojo Schweizer']) + }) + + test('should handle markdown bold formatting', () => { + const transcript = `**Dennis**: Hello everyone. +**Jojo**: Thanks for having me.` + + const result = extractSpeakerNames(transcript) + expect(result).toEqual(['Dennis', 'Jojo']) + }) + + test('should handle German umlauts', () => { + const transcript = `Jürgen: Guten Tag. +Björn: Hallo zusammen.` + + const result = extractSpeakerNames(transcript) + expect(result).toEqual(['Jürgen', 'Björn']) + }) + + test('should deduplicate speaker names', () => { + const transcript = `Dennis (00:01.000) +First point. + +Jojo (00:05.000) +I agree. + +Dennis (00:10.000) +Second point.` + + const result = extractSpeakerNames(transcript) + expect(result).toContain('Dennis') + expect(result).toContain('Jojo') + expect(result).toHaveLength(2) + }) + + test('should return empty array for transcript without speaker labels', () => { + const transcript = `This is just plain text without any speaker labels. +It continues on multiple lines.` + + const result = extractSpeakerNames(transcript) + expect(result).toEqual([]) + }) +}) + +describe('findMatchingMembers', () => { + const members: MemberData[] = [ + { id: '1', first_name: 'Dennis', last_name: 'Becker' }, + { id: '2', first_name: 'Jojo', last_name: 'Schweizer' }, + { id: '3', first_name: 'Fabi', last_name: 'Eckner' }, + { id: '4', first_name: 'Sebi', last_name: 'Müller' }, + { id: '5', first_name: 'Jan-Gregor', last_name: 'Emge-Triebel' }, + ] + + test('should match by first name', () => { + const speakerNames = ['Dennis', 'Jojo'] + const result = findMatchingMembers(speakerNames, members) + + expect(result).toHaveLength(2) + expect(result[0].id).toBe('1') + expect(result[1].id).toBe('2') + }) + + test('should match by full name', () => { + const speakerNames = ['Dennis Becker', 'Fabi Eckner'] + const result = findMatchingMembers(speakerNames, members) + + expect(result).toHaveLength(2) + expect(result[0].id).toBe('1') + expect(result[1].id).toBe('3') + }) + + test('should handle case insensitivity', () => { + const speakerNames = ['DENNIS', 'jojo'] + const result = findMatchingMembers(speakerNames, members) + + expect(result).toHaveLength(2) + expect(result[0].id).toBe('1') + expect(result[1].id).toBe('2') + }) + + test('should handle umlaut normalization', () => { + const speakerNames = ['Sebi Muller'] // without umlaut + const result = findMatchingMembers(speakerNames, members) + + expect(result).toHaveLength(1) + expect(result[0].id).toBe('4') + }) + + test('should not match unknown speakers', () => { + const speakerNames = ['Unknown Person', 'Another Guest'] + const result = findMatchingMembers(speakerNames, members) + + expect(result).toHaveLength(0) + }) + + test('should not duplicate members', () => { + const speakerNames = ['Dennis', 'Dennis Becker'] // Both would match Dennis + const result = findMatchingMembers(speakerNames, members) + + expect(result).toHaveLength(1) + expect(result[0].id).toBe('1') + }) + + test('should preserve order from transcript', () => { + const speakerNames = ['Fabi', 'Dennis', 'Jojo'] + const result = findMatchingMembers(speakerNames, members) + + expect(result).toHaveLength(3) + expect(result[0].first_name).toBe('Fabi') + expect(result[1].first_name).toBe('Dennis') + expect(result[2].first_name).toBe('Jojo') + }) + + test('should match names with space vs hyphen differences', () => { + // Transcript has "Jan Gregor" (space), DB has "Jan-Gregor" (hyphen) + const speakerNames = ['Jan Gregor Emge-Triebel'] + const result = findMatchingMembers(speakerNames, members) + + expect(result).toHaveLength(1) + expect(result[0].id).toBe('5') + expect(result[0].first_name).toBe('Jan-Gregor') + }) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/index.ts new file mode 100644 index 0000000..fc628b3 --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/index.ts @@ -0,0 +1,35 @@ +import { defineHook } from '@directus/extensions-sdk' +import { matchMembersFromTranscript } from './matchMembers.js' + +const HOOK_NAME = 'member-matching' + +export default defineHook(({ action }, hookContext) => { + const logger = hookContext.logger + const ItemsService = hookContext.services.ItemsService + const getSchema = hookContext.getSchema + + // Trigger on podcast update when transcript_text is added + action('podcasts.items.update', async function (metadata, eventContext) { + const { payload, keys } = metadata + + // Check if transcript_text was updated + const hasTranscriptText = payload.transcript_text && payload.transcript_text.trim().length > 0 + + if (!hasTranscriptText) { + return + } + + const podcastId = keys[0] + logger.info(`${HOOK_NAME} hook: Transcript detected for podcast ${podcastId}, attempting to match members`) + + // Run member matching (don't await - let it run alongside other hooks) + matchMembersFromTranscript(HOOK_NAME, podcastId, payload.transcript_text, { + logger, + ItemsService, + getSchema, + eventContext, + }).catch((err) => { + logger.error(`${HOOK_NAME} hook: Member matching failed for podcast ${podcastId}:`, err) + }) + }) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/matchMembers.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/matchMembers.ts new file mode 100644 index 0000000..9e54a08 --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/member-matching/matchMembers.ts @@ -0,0 +1,192 @@ +import type { Logger } from 'pino' + +interface HookServices { + logger: Logger + ItemsService: any + getSchema: () => Promise + eventContext: any +} + +export interface MemberData { + id: string + first_name: string + last_name: string +} + +/** + * Extract speaker names from transcript text. + * Handles formats like: + * - "Dennis: Hello everyone..." + * - "Dennis Becker: Hello everyone..." + * - "**Dennis**: Hello..." + * - "Jan Gregor Emge-Triebel (00:12.534)" (timestamp format) + */ +export function extractSpeakerNames(transcriptText: string): string[] { + const speakerSet = new Set() + + // Pattern 1: Name followed by timestamp in parentheses + // Examples: "Jan Gregor Emge-Triebel (00:12.534)", "Fabi Fink (00:35.735)" + const timestampPattern = /^([A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß\-]+(?:\s+[A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß\-]+)*)\s+\(\d{2}:\d{2}\.\d+\)/gm + + let match + while ((match = timestampPattern.exec(transcriptText)) !== null) { + const name = match[1].trim() + if (name.length >= 2) { + speakerSet.add(name) + } + } + + // Pattern 2: Name followed by colon (fallback for other transcript formats) + // Examples: "Dennis:", "Dennis Becker:", "**Jojo**:" + const colonPattern = /^(?:\*\*)?([A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß\-]+(?:\s+[A-ZÄÖÜa-zäöüß][A-ZÄÖÜa-zäöüß\-]+)?)(?:\*\*)?:/gm + + while ((match = colonPattern.exec(transcriptText)) !== null) { + const name = match[1].trim() + if (name.length >= 2) { + speakerSet.add(name) + } + } + + return Array.from(speakerSet) +} + +/** + * Normalize a string for fuzzy matching. + * Removes accents, lowercases, normalizes hyphens/spaces, and trims. + */ +function normalize(str: string): string { + return str + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // Remove accents + .replace(/ä/g, 'a') + .replace(/ö/g, 'o') + .replace(/ü/g, 'u') + .replace(/ß/g, 'ss') + .replace(/[-\s]+/g, ' ') // Normalize hyphens and multiple spaces to single space + .trim() +} + +/** + * Check if a speaker name matches a member. + * Matches against first name, last name, or full name. + */ +function matchesMember(speakerName: string, member: MemberData): boolean { + const normalizedSpeaker = normalize(speakerName) + const normalizedFirst = normalize(member.first_name) + const normalizedLast = normalize(member.last_name) + const normalizedFull = `${normalizedFirst} ${normalizedLast}` + + // Exact match on first name, last name, or full name + if (normalizedSpeaker === normalizedFirst) return true + if (normalizedSpeaker === normalizedLast) return true + if (normalizedSpeaker === normalizedFull) return true + + // Speaker name contains first name (for nicknames like "Jojo" matching "Johannes") + // Be careful: only match if the first name is reasonably unique (3+ chars) + if (normalizedFirst.length >= 3 && normalizedSpeaker.startsWith(normalizedFirst)) return true + + return false +} + +/** + * Find members that match speaker names from the transcript. + */ +export function findMatchingMembers(speakerNames: string[], members: MemberData[]): MemberData[] { + const matchedMembers: MemberData[] = [] + const matchedIds = new Set() + + for (const speakerName of speakerNames) { + for (const member of members) { + if (!matchedIds.has(member.id) && matchesMember(speakerName, member)) { + matchedMembers.push(member) + matchedIds.add(member.id) + break // One speaker name matches one member + } + } + } + + return matchedMembers +} + +export async function matchMembersFromTranscript( + hookName: string, + podcastId: number, + transcriptText: string, + services: HookServices +): Promise { + const { logger, ItemsService, getSchema, eventContext } = services + + try { + const schema = await getSchema() + + const podcastsService = new ItemsService('podcasts', { + schema, + accountability: eventContext.accountability, + }) + + const membersService = new ItemsService('members', { + schema, + accountability: eventContext.accountability, + }) + + // Fetch all members + const allMembers: MemberData[] = await membersService.readByQuery({ + fields: ['id', 'first_name', 'last_name'], + limit: -1, + }) + + logger.info(`${hookName}: Found ${allMembers.length} members in database`) + logger.info(`${hookName}: Members: ${allMembers.map((m) => `${m.first_name} ${m.last_name}`).join(', ')}`) + + // Extract speaker names from transcript + const speakerNames = extractSpeakerNames(transcriptText) + logger.info(`${hookName}: Extracted speaker names from transcript: [${speakerNames.join(', ')}]`) + logger.info(`${hookName}: First 500 chars of transcript: ${transcriptText.substring(0, 500)}`) + + if (speakerNames.length === 0) { + logger.info(`${hookName}: No speaker names found in transcript, skipping member matching`) + return + } + + // Match speakers to members + const matchedMembers = findMatchingMembers(speakerNames, allMembers) + logger.info( + `${hookName}: Matched ${matchedMembers.length} members: ${matchedMembers.map((m) => `${m.first_name} ${m.last_name}`).join(', ')}` + ) + + if (matchedMembers.length === 0) { + logger.info(`${hookName}: No members matched, skipping update`) + return + } + + // Check current members to avoid overwriting manual selections + const currentPodcast = await podcastsService.readOne(podcastId, { + fields: ['members.member'], + }) + + if (currentPodcast.members && currentPodcast.members.length > 0) { + logger.info(`${hookName}: Podcast already has ${currentPodcast.members.length} members assigned, skipping auto-assignment`) + return + } + + // Update podcast with matched members (M2M relation) + // Format: array of objects with member ID and sort order + const membersPayload = matchedMembers.map((member, index) => ({ + member: member.id, + sort: index + 1, + })) + + await podcastsService.updateOne(podcastId, { + members: membersPayload, + }) + + logger.info(`${hookName}: Successfully assigned ${matchedMembers.length} members to podcast ${podcastId}`) + } catch (err: any) { + logger.error(`${hookName}: Member matching error for podcast ${podcastId}: ${err?.message || err}`) + if (err?.stack) { + logger.error(`${hookName}: Stack trace: ${err.stack}`) + } + // Don't throw - member matching failure shouldn't block other operations + } +} diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/bluesky.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/bluesky.ts new file mode 100644 index 0000000..c15f458 --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/bluesky.ts @@ -0,0 +1,202 @@ +/** + * Bluesky publishing via AT Protocol + * + * Bluesky uses the AT Protocol. To post: + * 1. Authenticate with handle + app password to get a session + * 2. Create a post record using com.atproto.repo.createRecord + * + * App passwords can be created at: https://bsky.app/settings/app-passwords + */ + +interface BlueskyConfig { + handle: string + appPassword: string + logger: { info: (msg: string) => void; error: (msg: string) => void } +} + +interface BlueskySession { + did: string + accessJwt: string +} + +interface BlueskyFacet { + index: { byteStart: number; byteEnd: number } + features: Array<{ $type: string; uri?: string; did?: string; tag?: string }> +} + +const BSKY_SERVICE = 'https://bsky.social' + +/** + * Authenticate with Bluesky using app password + */ +async function createSession(handle: string, appPassword: string): Promise { + const response = await fetch(`${BSKY_SERVICE}/xrpc/com.atproto.server.createSession`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identifier: handle, + password: appPassword, + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Bluesky auth failed: ${response.status} ${error}`) + } + + const data = await response.json() + return { + did: data.did, + accessJwt: data.accessJwt, + } +} + +/** + * Detect URLs, mentions (@handle.bsky.social), and hashtags (#tag) in text + * and create facets for rich text formatting + */ +function detectFacets(text: string): BlueskyFacet[] { + const facets: BlueskyFacet[] = [] + const encoder = new TextEncoder() + + // Detect URLs + const urlRegex = /https?:\/\/[^\s<>\"{}|\\^`\[\]]+/g + let match: RegExpExecArray | null + while ((match = urlRegex.exec(text)) !== null) { + const byteStart = encoder.encode(text.slice(0, match.index)).length + const byteEnd = byteStart + encoder.encode(match[0]).length + facets.push({ + index: { byteStart, byteEnd }, + features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }], + }) + } + + // Detect mentions (@handle) + const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g + while ((match = mentionRegex.exec(text)) !== null) { + const byteStart = encoder.encode(text.slice(0, match.index)).length + const byteEnd = byteStart + encoder.encode(match[0]).length + // Note: In a full implementation, we'd resolve the handle to a DID here + // For now, we'll include the mention facet without the DID + facets.push({ + index: { byteStart, byteEnd }, + features: [{ $type: 'app.bsky.richtext.facet#mention', did: '' }], + }) + } + + // Detect hashtags (#tag) + const hashtagRegex = /#[a-zA-Z][a-zA-Z0-9_]*/g + while ((match = hashtagRegex.exec(text)) !== null) { + const byteStart = encoder.encode(text.slice(0, match.index)).length + const byteEnd = byteStart + encoder.encode(match[0]).length + facets.push({ + index: { byteStart, byteEnd }, + features: [{ $type: 'app.bsky.richtext.facet#tag', tag: match[0].slice(1) }], + }) + } + + // Filter out mentions without DIDs (they need to be resolved) + return facets.filter((f) => { + const mention = f.features.find((feat) => feat.$type === 'app.bsky.richtext.facet#mention') + return !mention || mention.did + }) +} + +/** + * Resolve a Bluesky handle to a DID + */ +async function resolveHandle(handle: string): Promise { + try { + // Remove @ prefix if present + const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle + + const response = await fetch( + `${BSKY_SERVICE}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(cleanHandle)}` + ) + + if (!response.ok) { + return null + } + + const data = await response.json() + return data.did || null + } catch { + return null + } +} + +/** + * Publish a post to Bluesky + */ +export async function publishToBluesky( + text: string, + config: BlueskyConfig +): Promise<{ postId: string; postUrl: string }> { + const { handle, appPassword, logger } = config + + logger.info(`Bluesky: Authenticating as ${handle}`) + const session = await createSession(handle, appPassword) + + // Detect and create facets for rich text + let facets = detectFacets(text) + + // Resolve mentions to DIDs + const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g + const encoder = new TextEncoder() + let match: RegExpExecArray | null + + while ((match = mentionRegex.exec(text)) !== null) { + const mentionHandle = match[0].slice(1) // Remove @ + const did = await resolveHandle(mentionHandle) + + if (did) { + const byteStart = encoder.encode(text.slice(0, match.index)).length + const byteEnd = byteStart + encoder.encode(match[0]).length + facets.push({ + index: { byteStart, byteEnd }, + features: [{ $type: 'app.bsky.richtext.facet#mention', did }], + }) + } + } + + // Build the post record + const record: Record = { + $type: 'app.bsky.feed.post', + text, + createdAt: new Date().toISOString(), + } + + if (facets.length > 0) { + record.facets = facets + } + + logger.info(`Bluesky: Creating post`) + const response = await fetch(`${BSKY_SERVICE}/xrpc/com.atproto.repo.createRecord`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.accessJwt}`, + }, + body: JSON.stringify({ + repo: session.did, + collection: 'app.bsky.feed.post', + record, + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Bluesky post failed: ${response.status} ${error}`) + } + + const result = await response.json() + + // Extract rkey from uri: at://did:plc:xxx/app.bsky.feed.post/rkey + const rkey = result.uri.split('/').pop() + const postUrl = `https://bsky.app/profile/${handle}/post/${rkey}` + + return { + postId: result.uri, + postUrl, + } +} diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/index.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/index.ts new file mode 100644 index 0000000..2cb8fe2 --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/index.ts @@ -0,0 +1,110 @@ +import { defineHook } from '@directus/extensions-sdk' +import { publishToBluesky } from './bluesky.js' +import { publishToMastodon } from './mastodon.js' + +const HOOK_NAME = 'social-media-publish' + +export default defineHook(({ action }, hookContext) => { + const logger = hookContext.logger + const env = hookContext.env + const ItemsService = hookContext.services.ItemsService + const getSchema = hookContext.getSchema + + // Check if any social media credentials are configured + const hasBluesky = env.BLUESKY_HANDLE && env.BLUESKY_APP_PASSWORD + const hasMastodon = env.MASTODON_INSTANCE_URL && env.MASTODON_ACCESS_TOKEN + + if (!hasBluesky && !hasMastodon) { + logger.warn(`${HOOK_NAME}: No social media credentials configured. Publishing will not be active.`) + return + } + + logger.info(`${HOOK_NAME}: Initialized with platforms: ${[hasBluesky && 'Bluesky', hasMastodon && 'Mastodon'].filter(Boolean).join(', ')}`) + + // Trigger when a social_media_posts item is updated to 'scheduled' and scheduled_for is now or in the past + // OR when manually triggered by setting status to 'publishing' + action('social_media_posts.items.update', async function (metadata, eventContext) { + const { payload, keys } = metadata + + // Only proceed if status is being set to 'publishing' + if (payload.status !== 'publishing') { + return + } + + const schema = await getSchema() + const postsService = new ItemsService('social_media_posts', { + schema, + accountability: eventContext.accountability, + }) + + for (const postId of keys) { + try { + const post = await postsService.readOne(postId, { + fields: ['id', 'platform', 'post_text', 'tags', 'podcast_id'], + }) + + if (!post) { + logger.warn(`${HOOK_NAME}: Post ${postId} not found`) + continue + } + + logger.info(`${HOOK_NAME}: Publishing post ${postId} to ${post.platform}`) + + let result: { postId: string; postUrl: string } | null = null + + switch (post.platform) { + case 'bluesky': + if (!hasBluesky) { + throw new Error('Bluesky credentials not configured') + } + result = await publishToBluesky(post.post_text, { + handle: env.BLUESKY_HANDLE, + appPassword: env.BLUESKY_APP_PASSWORD, + logger, + }) + break + + case 'mastodon': + if (!hasMastodon) { + throw new Error('Mastodon credentials not configured') + } + result = await publishToMastodon(post.post_text, { + instanceUrl: env.MASTODON_INSTANCE_URL, + accessToken: env.MASTODON_ACCESS_TOKEN, + logger, + }) + break + + case 'linkedin': + case 'instagram': + // Not implemented yet + throw new Error(`${post.platform} publishing not yet implemented`) + + default: + throw new Error(`Unknown platform: ${post.platform}`) + } + + if (result) { + await postsService.updateOne(postId, { + status: 'published', + platform_post_id: result.postId, + platform_post_url: result.postUrl, + published_at: new Date().toISOString(), + error_message: null, + }) + + logger.info(`${HOOK_NAME}: Successfully published post ${postId} to ${post.platform}: ${result.postUrl}`) + } + } catch (err: any) { + logger.error(`${HOOK_NAME}: Failed to publish post ${postId}: ${err?.message || err}`) + + await postsService.updateOne(postId, { + status: 'failed', + error_message: err?.message || String(err), + }) + } + } + }) + + logger.info(`${HOOK_NAME} hook registered`) +}) diff --git a/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/mastodon.ts b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/mastodon.ts new file mode 100644 index 0000000..7c2a3dd --- /dev/null +++ b/directus-cms/extensions/directus-extension-programmierbar-bundle/src/social-media-publish/mastodon.ts @@ -0,0 +1,132 @@ +/** + * Mastodon publishing via standard Mastodon API + * + * Mastodon uses a simple REST API with OAuth access tokens. + * To post: + * 1. Use the access token (obtained from app settings) + * 2. POST to /api/v1/statuses + * + * Access tokens can be obtained from: Settings > Development > New Application + */ + +interface MastodonConfig { + instanceUrl: string + accessToken: string + logger: { info: (msg: string) => void; error: (msg: string) => void } +} + +interface MastodonStatus { + id: string + url: string + uri: string + created_at: string + content: string +} + +/** + * Publish a status (toot) to Mastodon + */ +export async function publishToMastodon( + text: string, + config: MastodonConfig +): Promise<{ postId: string; postUrl: string }> { + const { instanceUrl, accessToken, logger } = config + + // Ensure instance URL doesn't have trailing slash + const baseUrl = instanceUrl.replace(/\/$/, '') + + logger.info(`Mastodon: Posting to ${baseUrl}`) + + const response = await fetch(`${baseUrl}/api/v1/statuses`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + status: text, + visibility: 'public', + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Mastodon post failed: ${response.status} ${error}`) + } + + const status: MastodonStatus = await response.json() + + logger.info(`Mastodon: Successfully posted status ${status.id}`) + + return { + postId: status.id, + postUrl: status.url, + } +} + +/** + * Schedule a status for future publication + * Note: Mastodon supports scheduled posts natively + */ +export async function scheduleToMastodon( + text: string, + scheduledAt: Date, + config: MastodonConfig +): Promise<{ scheduledId: string }> { + const { instanceUrl, accessToken, logger } = config + + const baseUrl = instanceUrl.replace(/\/$/, '') + + logger.info(`Mastodon: Scheduling post for ${scheduledAt.toISOString()}`) + + const response = await fetch(`${baseUrl}/api/v1/statuses`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + status: text, + visibility: 'public', + scheduled_at: scheduledAt.toISOString(), + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Mastodon schedule failed: ${response.status} ${error}`) + } + + const result = await response.json() + + return { + scheduledId: result.id, + } +} + +/** + * Verify the access token is valid by fetching account info + */ +export async function verifyCredentials(config: MastodonConfig): Promise<{ username: string; url: string }> { + const { instanceUrl, accessToken } = config + + const baseUrl = instanceUrl.replace(/\/$/, '') + + const response = await fetch(`${baseUrl}/api/v1/accounts/verify_credentials`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Mastodon auth verification failed: ${response.status} ${error}`) + } + + const account = await response.json() + + return { + username: account.username, + url: account.url, + } +} diff --git a/directus-cms/package.json b/directus-cms/package.json index e391aab..02bfdd9 100644 --- a/directus-cms/package.json +++ b/directus-cms/package.json @@ -13,7 +13,10 @@ "snapshot-schema": "directus schema snapshot ./schema.json --format json", "apply-schema": "directus schema apply ./schema.json", "setup-local": "node ./utils/setup-local.mjs", - "setup-local:with-data": "node ./utils/setup-local.mjs --import-data" + "setup-local:with-data": "node ./utils/setup-local.mjs --import-data", + "add-automation-fields": "node ./utils/add-automation-fields.mjs", + "setup-flows": "node ./utils/setup-flows.mjs", + "generate-speaker-tokens": "node ./utils/generate-speaker-tokens.mjs" }, "dependencies": { "directus": "^11.0", diff --git a/directus-cms/prompts/shownotes.md b/directus-cms/prompts/shownotes.md new file mode 100644 index 0000000..07f46d7 --- /dev/null +++ b/directus-cms/prompts/shownotes.md @@ -0,0 +1,111 @@ +# Shownotes Generation Prompt + +## System Prompt + +Du bist ein erfahrener Content-Redakteur für den deutschen Entwickler-Podcast "programmier.bar". +Deine Aufgabe ist es, ansprechende Shownotes für Podcast-Episoden zu erstellen, die sowohl informativ +als auch einladend sind. + +### Stilrichtlinien + +**Sprache & Ton:** +- Deutsch als Hauptsprache +- Technische Fachbegriffe auf Englisch belassen (z.B. "TypeScript", "Machine Learning", "API") +- Freundlich, professionell, aber nicht steif +- Direkte Ansprache möglich ("In dieser Episode erfahrt ihr...") + +**Struktur:** +1. Einleitender Absatz (2-4 Sätze): Hook mit Thema und Gast +2. Hauptteil: Themen als Bullet Points oder kurze Absätze +3. Ressourcen/Links Sektion (optional): Erwähnte Tools und Referenzen +4. Call-to-Action (1-2 Sätze): Engagement-Aufforderung + +**Formatierung:** +- HTML-Formatierung erlaubt: ``, ``, `
          `, `
            `, `
          1. `, `` +- Bullet Points für Themenübersicht verwenden +- Links zu erwähnten Ressourcen einbinden +- Länge: 150-500 Wörter (je nach Episodentyp) + +**Episode-Typ-spezifisch:** +- **Deep Dive**: Längere, technisch detaillierte Beschreibungen (300-500 Wörter) +- **CTO Special**: Fokus auf Leadership und Business-Perspektive (150-300 Wörter) +- **News**: Kurz, aktuell, punchy (100-200 Wörter) + +## User Prompt Template + +``` +Erstelle Shownotes für folgende Podcast-Episode: + +**Episode-Typ:** {{episode_type}} +**Titel:** {{title}} +**Episodennummer:** {{number}} + +**Hosts:** {{hosts}} +**Gäste:** {{guests}} +{{#if guest_info}} +**Gast-Info:** {{guest_info}} +{{/if}} + +**Transkript:** +{{transcript}} + +--- + +Erstelle basierend auf dem Transkript: + +1. **Beschreibung** ({{word_count_target}} Wörter): Eine einladende Episode-Beschreibung im programmier.bar Stil + +2. **Themenübersicht**: 3-7 Hauptthemen als Bullet Points + +3. **Timestamps**: Wichtige Zeitmarken für Themenwechsel (Format: MM:SS - Thema) + +4. **Ressourcen**: Liste der im Gespräch erwähnten Tools, Technologien, Links + +Formatiere die Beschreibung in HTML mit ,
              ,
            • , und Tags wo angemessen. +``` + +## Example Output + +```html +

              In dieser Deep Dive Episode sprechen wir mit Max Mustermann, CTO bei TechCorp, +über die Herausforderungen moderner Microservice-Architekturen.

              + +

              Themen in dieser Episode:

              +
                +
              • Wann lohnt sich der Umstieg auf Microservices?
              • +
              • Service Mesh mit Istio: Erfahrungen aus der Praxis
              • +
              • Observability und Debugging in verteilten Systemen
              • +
              • Team-Organisation und Conway's Law
              • +
              + +

              Max teilt seine Erfahrungen aus 5 Jahren Microservice-Migration und erklärt, +welche Fehler ihr vermeiden solltet.

              + +

              Links:

              +
              + +

              Hört rein und lasst uns wissen, wie ihr eure Services strukturiert!

              +``` + +## Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `episode_type` | Type of episode | `deep_dive`, `cto_special`, `news`, `other` | +| `title` | Episode title | "Deep Dive TypeScript" | +| `number` | Episode number | "100" | +| `hosts` | Comma-separated host names | "Fabi, Jojo, Sebi" | +| `guests` | Comma-separated guest names | "Max Mustermann" | +| `guest_info` | Guest bio/company info | "CTO at TechCorp, 10 Jahre Erfahrung..." | +| `transcript` | Full transcript text | Full transcript with speaker labels | +| `word_count_target` | Target word count | "300-400" (based on episode type) | + +## Word Count Targets by Type + +- `deep_dive`: 300-500 words +- `cto_special`: 200-350 words +- `news`: 100-200 words +- `other`: 150-400 words diff --git a/directus-cms/prompts/social-media.md b/directus-cms/prompts/social-media.md new file mode 100644 index 0000000..43917d7 --- /dev/null +++ b/directus-cms/prompts/social-media.md @@ -0,0 +1,257 @@ +# Social Media Post Generation Prompts + +## Overview + +This file contains prompt templates for generating social media posts for podcast episodes +across different platforms. Each platform has specific requirements and character limits. + +--- + +## LinkedIn Post Prompt + +### System Prompt + +Du erstellst professionelle LinkedIn-Posts für den Podcast "programmier.bar". +Der Ton ist professionell aber nahbar, fachlich fundiert aber zugänglich. + +**Richtlinien:** +- Professioneller, aber nicht steifer Ton +- Hashtags am Ende (3-5 relevante) +- Tagging von Gästen und deren Unternehmen wo möglich +- Maximal 3000 Zeichen, ideal 1300-1800 Zeichen +- Call-to-Action zum Anhören +- Emoji sparsam einsetzen (1-3 pro Post) + +### User Prompt Template + +``` +Erstelle einen LinkedIn-Post für diese Podcast-Episode: + +**Titel:** {{title}} +**Typ:** {{episode_type}} +**Gäste:** {{guests}} +**Unternehmen:** {{guest_companies}} +**Kurzbeschreibung:** {{description}} + +**Key Topics:** +{{topics}} + +--- + +Erstelle einen LinkedIn-Post mit: +1. Hook (erste 2 Zeilen sind am wichtigsten - vor "mehr anzeigen") +2. 2-3 Key Takeaways oder interessante Punkte +3. Call-to-Action mit Link-Platzhalter +4. 3-5 relevante Hashtags + +Gib auch an, welche Personen/Unternehmen getaggt werden sollten. +``` + +### Example Output + +``` +🎙️ Neue Episode: Deep Dive in Microservices + +"Microservices sind kein Allheilmittel" - @MaxMustermann, CTO bei @TechCorp, +teilt seine ehrlichen Erfahrungen aus 5 Jahren Migration. + +Was wir besprechen: +📌 Wann Microservices Sinn machen (und wann nicht) +📌 Die versteckten Kosten verteilter Systeme +📌 Praktische Tipps für Service Mesh mit Istio + +💡 Key Insight: "Start with a modular monolith. Nur wenn ihr echte +Skalierungsprobleme habt, denkt über Microservices nach." + +🎧 Jetzt anhören: [LINK] + +#Microservices #SoftwareArchitecture #programmierbar #TechPodcast #DevCommunity + +--- +Tagging-Vorschläge: +- @MaxMustermann (Gast) +- @TechCorp (Unternehmen) +``` + +--- + +## Instagram Post Prompt + +### System Prompt + +Du erstellst Instagram-Posts für "programmier.bar". Instagram ist visuell-fokussiert, +der Text ist die Caption für ein Episode-Bild oder Karussell. + +**Richtlinien:** +- Kürzerer, punchiger Text +- Hashtags sind wichtig (10-15 relevante) +- Emojis sind erlaubt und erwünscht +- Max 2200 Zeichen, ideal 150-300 Zeichen vor Hashtags +- Persönlicher, Community-fokussierter Ton +- Call-to-Action: "Link in Bio" + +### User Prompt Template + +``` +Erstelle eine Instagram-Caption für diese Podcast-Episode: + +**Titel:** {{title}} +**Typ:** {{episode_type}} +**Gäste:** {{guests}} +**Key Topics:** {{topics}} + +--- + +Erstelle eine Instagram-Caption mit: +1. Aufmerksamkeitsstarke erste Zeile +2. 2-3 Sätze zum Inhalt +3. Call-to-Action ("Link in Bio") +4. 10-15 relevante Hashtags (Mix aus großen und Nischen-Tags) +``` + +### Example Output + +``` +🔥 Microservices: Hype vs. Realität + +Max von @techcorp packt aus - nach 5 Jahren Migration weiß er, +was wirklich funktioniert (und was nicht). Spoiler: Es ist komplizierter +als die Tutorials versprechen 😅 + +🎧 Jetzt reinhören - Link in Bio! + +. +. +. +#programmierbar #techpodcast #webdev #softwaredevelopment #microservices +#devlife #coding #softwarearchitecture #backend #cloudnative #kubernetes +#developer #techcommunity #learncoding #deutschepodcasts +``` + +--- + +## Bluesky Post Prompt + +### System Prompt + +Du erstellst Posts für Bluesky für "programmier.bar". Bluesky ist ähnlich wie Twitter, +mit 300 Zeichen Limit pro Post. + +**Richtlinien:** +- Max 300 Zeichen +- Kurz und prägnant +- Hashtags optional (1-3 wenn Platz) +- Link wird automatisch eingekürzt +- Kein Thread, nur einzelner Post +- Casual-professioneller Ton + +### User Prompt Template + +``` +Erstelle einen Bluesky-Post für diese Podcast-Episode: + +**Titel:** {{title}} +**Gäste:** {{guests}} +**Ein Key Point:** {{main_takeaway}} + +--- + +Erstelle einen Bluesky-Post (max 300 Zeichen inkl. Link-Platzhalter) mit: +1. Hook oder interessantes Zitat +2. Kurze Info zur Episode +3. Platz für Link +``` + +### Example Output + +``` +"Startet nicht mit Microservices, startet mit einem modularen Monolithen" +- @maxmustermann + +Neue Episode über die Realität hinter dem Microservices-Hype 🎙️ + +[LINK] +``` + +--- + +## Mastodon Post Prompt + +### System Prompt + +Du erstellst Posts für Mastodon für "programmier.bar". Mastodon hat ein 500 Zeichen Limit +und eine tech-affine, Community-orientierte Nutzerschaft. + +**Richtlinien:** +- Max 500 Zeichen +- Hashtags sind wichtig für Discoverability (3-5) +- Tech-Community schätzt Substanz +- Content Warnings (CW) nur wenn nötig +- Kein übertriebenes Marketing-Speak +- Casual, authentischer Ton + +### User Prompt Template + +``` +Erstelle einen Mastodon-Post für diese Podcast-Episode: + +**Titel:** {{title}} +**Gäste:** {{guests}} +**Topics:** {{topics}} + +--- + +Erstelle einen Mastodon-Post (max 500 Zeichen) mit: +1. Beschreibung der Episode +2. Was Hörer:innen lernen können +3. Link-Platzhalter +4. 3-5 Hashtags +``` + +### Example Output + +``` +Neue Episode! 🎙️ + +Wir sprechen mit Max Mustermann (@maxmustermann@tech.social) über +Microservices in der Praxis. + +Nach 5 Jahren Migration teilt er: +- Wann Microservices wirklich Sinn machen +- Die versteckten Kosten (Team-Overhead, Debugging, Ops) +- Warum ein modularer Monolith oft der bessere Start ist + +[LINK] + +#programmierbar #podcast #microservices #softwarearchitecture #webdev +``` + +--- + +## Platform Comparison + +| Platform | Character Limit | Hashtags | Tone | Emoji | +|----------|-----------------|----------|------|-------| +| LinkedIn | 3000 | 3-5 | Professional | Sparsam | +| Instagram | 2200 | 10-15 | Casual/Community | Ja | +| Bluesky | 300 | 1-3 | Casual-Professional | Sparsam | +| Mastodon | 500 | 3-5 | Authentic/Tech | Moderat | + +## Variables + +| Variable | Description | +|----------|-------------| +| `title` | Episode title | +| `episode_type` | deep_dive, cto_special, news, other | +| `guests` | Guest names | +| `guest_companies` | Companies/organizations of guests | +| `description` | Short episode description | +| `topics` | Bullet list of main topics | +| `main_takeaway` | Single most interesting point | + +## Best Posting Times (German Audience) + +- **LinkedIn**: Tuesday-Thursday, 8-10am or 12-2pm +- **Instagram**: Monday-Friday, 11am-1pm or 7-9pm +- **Bluesky**: Tuesday-Thursday, 9-11am +- **Mastodon**: Weekdays, 10am-12pm or 6-8pm diff --git a/directus-cms/schema.json b/directus-cms/schema.json index 8d9bbb4..130aefd 100644 --- a/directus-cms/schema.json +++ b/directus-cms/schema.json @@ -1694,6 +1694,34 @@ "schema": { "name": "transcripts" } + }, + { + "collection": "podcast_generated_content", + "meta": { + "accountability": "all", + "archive_app_filter": true, + "archive_field": "status", + "archive_value": "archived", + "collapse": "open", + "collection": "podcast_generated_content", + "color": null, + "display_template": null, + "group": "Podcasts", + "hidden": false, + "icon": "auto_awesome", + "item_duplication_fields": null, + "note": "AI-generated content for podcast episodes", + "preview_url": null, + "singleton": false, + "sort": 5, + "sort_field": null, + "translations": null, + "unarchive_value": "draft", + "versioning": false + }, + "schema": { + "name": "podcast_generated_content" + } } ], "fields": [ @@ -29479,121 +29507,1341 @@ "foreign_key_table": null, "foreign_key_column": null } - } - ], - "systemFields": [ - { - "collection": "directus_activity", - "field": "timestamp", - "schema": { - "is_indexed": true - } }, { - "collection": "directus_revisions", - "field": "activity", - "schema": { - "is_indexed": true - } - }, - { - "collection": "directus_revisions", - "field": "parent", - "schema": { - "is_indexed": true - } - } - ], - "relations": [ - { - "collection": "about_page", - "field": "created_by", - "related_collection": "directus_users", + "collection": "podcasts", + "field": "recording_date", + "type": "timestamp", "meta": { - "junction_field": null, - "many_collection": "about_page", - "many_field": "created_by", - "one_allowed_collections": null, - "one_collection": "directus_users", - "one_collection_field": null, - "one_deselect_action": "nullify", - "one_field": null, - "sort_field": null + "collection": "podcasts", + "conditions": null, + "display": "datetime", + "display_options": { + "relative": false + }, + "field": "recording_date", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "When the episode will be/was recorded", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 38, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" }, "schema": { - "table": "about_page", - "column": "created_by", - "foreign_key_table": "directus_users", - "foreign_key_column": "id", - "constraint_name": "about_page_created_by_foreign", - "on_update": "NO ACTION", - "on_delete": "NO ACTION" + "name": "recording_date", + "table": "podcasts", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null } }, { - "collection": "about_page", - "field": "updated_by", - "related_collection": "directus_users", + "collection": "podcasts", + "field": "planned_publish_date", + "type": "timestamp", "meta": { - "junction_field": null, - "many_collection": "about_page", - "many_field": "updated_by", - "one_allowed_collections": null, - "one_collection": "directus_users", - "one_collection_field": null, - "one_deselect_action": "nullify", - "one_field": null, - "sort_field": null + "collection": "podcasts", + "conditions": null, + "display": "datetime", + "display_options": { + "relative": false + }, + "field": "planned_publish_date", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Target publication date", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 39, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" }, "schema": { - "table": "about_page", - "column": "updated_by", - "foreign_key_table": "directus_users", - "foreign_key_column": "id", - "constraint_name": "about_page_updated_by_foreign", - "on_update": "NO ACTION", - "on_delete": "NO ACTION" + "name": "planned_publish_date", + "table": "podcasts", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null } }, { - "collection": "about_page", - "field": "cover_image", - "related_collection": "directus_files", + "collection": "podcasts", + "field": "publishing_status", + "type": "string", "meta": { - "junction_field": null, - "many_collection": "about_page", - "many_field": "cover_image", - "one_allowed_collections": null, - "one_collection": "directus_files", - "one_collection_field": null, - "one_deselect_action": "nullify", - "one_field": null, - "sort_field": null + "collection": "podcasts", + "conditions": null, + "display": "labels", + "display_options": { + "showAsDot": true, + "choices": [ + { + "value": "planned", + "background": "#6B7280", + "foreground": "#FFFFFF" + }, + { + "value": "recorded", + "background": "#3B82F6", + "foreground": "#FFFFFF" + }, + { + "value": "transcribing", + "background": "#F59E0B", + "foreground": "#FFFFFF" + }, + { + "value": "transcript_ready", + "background": "#8B5CF6", + "foreground": "#FFFFFF" + }, + { + "value": "content_review", + "background": "#EC4899", + "foreground": "#FFFFFF" + }, + { + "value": "approved", + "background": "#10B981", + "foreground": "#FFFFFF" + }, + { + "value": "published", + "background": "#00C897", + "foreground": "#FFFFFF" + } + ] + }, + "field": "publishing_status", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "Workflow status for the publishing pipeline", + "options": { + "choices": [ + { + "text": "Planned", + "value": "planned" + }, + { + "text": "Recorded", + "value": "recorded" + }, + { + "text": "Transcribing", + "value": "transcribing" + }, + { + "text": "Transcript Ready", + "value": "transcript_ready" + }, + { + "text": "Content Review", + "value": "content_review" + }, + { + "text": "Approved", + "value": "approved" + }, + { + "text": "Published", + "value": "published" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 40, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" }, "schema": { - "table": "about_page", - "column": "cover_image", - "foreign_key_table": "directus_files", - "foreign_key_column": "id", - "constraint_name": "about_page_cover_image_foreign", - "on_update": "NO ACTION", - "on_delete": "SET NULL" + "name": "publishing_status", + "table": "podcasts", + "data_type": "character varying", + "default_value": "planned", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null } }, { - "collection": "badges", - "field": "user_created", - "related_collection": "directus_users", + "collection": "speakers", + "field": "portal_token", + "type": "string", "meta": { - "junction_field": null, - "many_collection": "badges", - "many_field": "user_created", - "one_allowed_collections": null, - "one_collection": "directus_users", - "one_collection_field": null, - "one_deselect_action": "nullify", - "one_field": null, - "sort_field": null + "collection": "speakers", + "conditions": null, + "display": "raw", + "display_options": null, + "field": "portal_token", + "group": null, + "hidden": true, + "interface": "input", + "note": "Unique token for self-service portal access", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 36, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "portal_token", + "table": "speakers", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": true, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "speakers", + "field": "portal_token_expires", + "type": "timestamp", + "meta": { + "collection": "speakers", + "conditions": null, + "display": "datetime", + "display_options": { + "relative": true + }, + "field": "portal_token_expires", + "group": null, + "hidden": true, + "interface": "datetime", + "note": "Token expiration date", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 37, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "portal_token_expires", + "table": "speakers", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "speakers", + "field": "portal_submission_status", + "type": "string", + "meta": { + "collection": "speakers", + "conditions": null, + "display": "labels", + "display_options": { + "showAsDot": true, + "choices": [ + { + "value": "pending", + "background": "#F59E0B", + "foreground": "#FFFFFF" + }, + { + "value": "submitted", + "background": "#3B82F6", + "foreground": "#FFFFFF" + }, + { + "value": "approved", + "background": "#10B981", + "foreground": "#FFFFFF" + } + ] + }, + "field": "portal_submission_status", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "Speaker portal submission status", + "options": { + "choices": [ + { + "text": "Pending", + "value": "pending" + }, + { + "text": "Submitted", + "value": "submitted" + }, + { + "text": "Approved", + "value": "approved" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 38, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "portal_submission_status", + "table": "speakers", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "speakers", + "field": "portal_submission_deadline", + "type": "timestamp", + "meta": { + "collection": "speakers", + "conditions": null, + "display": "datetime", + "display_options": { + "relative": true + }, + "field": "portal_submission_deadline", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "Deadline for speaker to submit info", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 39, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "portal_submission_deadline", + "table": "speakers", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcast_generated_content", + "field": "id", + "type": "uuid", + "meta": { + "collection": "podcast_generated_content", + "conditions": null, + "display": null, + "display_options": null, + "field": "id", + "group": null, + "hidden": true, + "interface": "input", + "note": null, + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 1, + "special": [ + "uuid" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "id", + "table": "podcast_generated_content", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": true, + "is_indexed": false, + "is_primary_key": true, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcast_generated_content", + "field": "podcast_id", + "type": "uuid", + "meta": { + "collection": "podcast_generated_content", + "conditions": null, + "display": "related-values", + "display_options": { + "template": "{{title}}" + }, + "field": "podcast_id", + "group": null, + "hidden": false, + "interface": "select-dropdown-m2o", + "note": "Related podcast episode", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 2, + "special": [ + "m2o" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "podcast_id", + "table": "podcast_generated_content", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "podcasts", + "foreign_key_column": "id" + } + }, + { + "collection": "podcast_generated_content", + "field": "content_type", + "type": "string", + "meta": { + "collection": "podcast_generated_content", + "conditions": null, + "display": "labels", + "display_options": null, + "field": "content_type", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "Type of generated content", + "options": { + "choices": [ + { + "text": "Shownotes", + "value": "shownotes" + }, + { + "text": "LinkedIn", + "value": "social_linkedin" + }, + { + "text": "Instagram", + "value": "social_instagram" + }, + { + "text": "Bluesky", + "value": "social_bluesky" + }, + { + "text": "Mastodon", + "value": "social_mastodon" + }, + { + "text": "Heise Document", + "value": "heise_document" + } + ] + }, + "readonly": false, + "required": true, + "searchable": true, + "sort": 3, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "content_type", + "table": "podcast_generated_content", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcast_generated_content", + "field": "generated_text", + "type": "text", + "meta": { + "collection": "podcast_generated_content", + "conditions": null, + "display": "formatted-value", + "display_options": null, + "field": "generated_text", + "group": null, + "hidden": false, + "interface": "input-rich-text-md", + "note": "AI-generated content", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "generated_text", + "table": "podcast_generated_content", + "data_type": "text", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcast_generated_content", + "field": "edited_text", + "type": "text", + "meta": { + "collection": "podcast_generated_content", + "conditions": null, + "display": "formatted-value", + "display_options": null, + "field": "edited_text", + "group": null, + "hidden": false, + "interface": "input-rich-text-md", + "note": "Human-edited version (if modified)", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "full" + }, + "schema": { + "name": "edited_text", + "table": "podcast_generated_content", + "data_type": "text", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcast_generated_content", + "field": "status", + "type": "string", + "meta": { + "collection": "podcast_generated_content", + "conditions": null, + "display": "labels", + "display_options": { + "showAsDot": true, + "choices": [ + { + "value": "generated", + "background": "#6B7280", + "foreground": "#FFFFFF" + }, + { + "value": "approved", + "background": "#10B981", + "foreground": "#FFFFFF" + }, + { + "value": "rejected", + "background": "#EF4444", + "foreground": "#FFFFFF" + }, + { + "value": "published", + "background": "#00C897", + "foreground": "#FFFFFF" + } + ] + }, + "field": "status", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "Content approval status", + "options": { + "choices": [ + { + "text": "Generated", + "value": "generated" + }, + { + "text": "Approved", + "value": "approved" + }, + { + "text": "Rejected", + "value": "rejected" + }, + { + "text": "Published", + "value": "published" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 6, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "status", + "table": "podcast_generated_content", + "data_type": "character varying", + "default_value": "generated", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcast_generated_content", + "field": "generated_at", + "type": "timestamp", + "meta": { + "collection": "podcast_generated_content", + "conditions": null, + "display": "datetime", + "display_options": { + "relative": true + }, + "field": "generated_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "When content was generated", + "options": null, + "readonly": true, + "required": false, + "searchable": true, + "sort": 7, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "generated_at", + "table": "podcast_generated_content", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcast_generated_content", + "field": "approved_at", + "type": "timestamp", + "meta": { + "collection": "podcast_generated_content", + "conditions": null, + "display": "datetime", + "display_options": { + "relative": true + }, + "field": "approved_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "When content was approved", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 8, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "approved_at", + "table": "podcast_generated_content", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcast_generated_content", + "field": "approved_by", + "type": "uuid", + "meta": { + "collection": "podcast_generated_content", + "conditions": null, + "display": "user", + "display_options": null, + "field": "approved_by", + "group": null, + "hidden": false, + "interface": "select-dropdown-m2o", + "note": "User who approved the content", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 9, + "special": [ + "m2o" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "approved_by", + "table": "podcast_generated_content", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "directus_users", + "foreign_key_column": "id" + } + }, + { + "collection": "podcast_generated_content", + "field": "llm_model", + "type": "string", + "meta": { + "collection": "podcast_generated_content", + "conditions": null, + "display": "raw", + "display_options": null, + "field": "llm_model", + "group": null, + "hidden": false, + "interface": "input", + "note": "Which LLM model was used", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 10, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "llm_model", + "table": "podcast_generated_content", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcast_generated_content", + "field": "prompt_version", + "type": "string", + "meta": { + "collection": "podcast_generated_content", + "conditions": null, + "display": "raw", + "display_options": null, + "field": "prompt_version", + "group": null, + "hidden": false, + "interface": "input", + "note": "Version of the prompt used", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 11, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "prompt_version", + "table": "podcast_generated_content", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcasts", + "field": "heise_eligible", + "type": "boolean", + "meta": { + "collection": "podcasts", + "conditions": null, + "display": "boolean", + "display_options": null, + "field": "heise_eligible", + "group": null, + "hidden": false, + "interface": "boolean", + "note": "Whether this episode should go to Heise.de", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 41, + "special": [ + "cast-boolean" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "heise_eligible", + "table": "podcasts", + "data_type": "boolean", + "default_value": false, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": false, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcasts", + "field": "heise_document_status", + "type": "string", + "meta": { + "collection": "podcasts", + "conditions": null, + "display": "labels", + "display_options": { + "showAsDot": true, + "choices": [ + { + "value": "not_applicable", + "background": "#6B7280", + "foreground": "#FFFFFF" + }, + { + "value": "pending", + "background": "#F59E0B", + "foreground": "#FFFFFF" + }, + { + "value": "generated", + "background": "#3B82F6", + "foreground": "#FFFFFF" + }, + { + "value": "approved", + "background": "#8B5CF6", + "foreground": "#FFFFFF" + }, + { + "value": "sent", + "background": "#10B981", + "foreground": "#FFFFFF" + } + ] + }, + "field": "heise_document_status", + "group": null, + "hidden": false, + "interface": "select-dropdown", + "note": "Status of Heise.de document", + "options": { + "choices": [ + { + "text": "Not Applicable", + "value": "not_applicable" + }, + { + "text": "Pending", + "value": "pending" + }, + { + "text": "Generated", + "value": "generated" + }, + { + "text": "Approved", + "value": "approved" + }, + { + "text": "Sent", + "value": "sent" + } + ] + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 42, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "heise_document_status", + "table": "podcasts", + "data_type": "character varying", + "default_value": "not_applicable", + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcasts", + "field": "heise_sent_at", + "type": "timestamp", + "meta": { + "collection": "podcasts", + "conditions": null, + "display": "datetime", + "display_options": { + "relative": true + }, + "field": "heise_sent_at", + "group": null, + "hidden": false, + "interface": "datetime", + "note": "When email was sent to Heise.de", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 43, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "heise_sent_at", + "table": "podcasts", + "data_type": "timestamp with time zone", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } + }, + { + "collection": "podcasts", + "field": "heise_document", + "type": "uuid", + "meta": { + "collection": "podcasts", + "conditions": null, + "display": "file", + "display_options": null, + "field": "heise_document", + "group": null, + "hidden": false, + "interface": "file", + "note": "Generated Heise.de document", + "options": null, + "readonly": false, + "required": false, + "searchable": true, + "sort": 44, + "special": [ + "file" + ], + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "heise_document", + "table": "podcasts", + "data_type": "uuid", + "default_value": null, + "max_length": null, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": "directus_files", + "foreign_key_column": "id" + } + } + ], + "systemFields": [ + { + "collection": "directus_activity", + "field": "timestamp", + "schema": { + "is_indexed": true + } + }, + { + "collection": "directus_revisions", + "field": "activity", + "schema": { + "is_indexed": true + } + }, + { + "collection": "directus_revisions", + "field": "parent", + "schema": { + "is_indexed": true + } + } + ], + "relations": [ + { + "collection": "about_page", + "field": "created_by", + "related_collection": "directus_users", + "meta": { + "junction_field": null, + "many_collection": "about_page", + "many_field": "created_by", + "one_allowed_collections": null, + "one_collection": "directus_users", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "about_page", + "column": "created_by", + "foreign_key_table": "directus_users", + "foreign_key_column": "id", + "constraint_name": "about_page_created_by_foreign", + "on_update": "NO ACTION", + "on_delete": "NO ACTION" + } + }, + { + "collection": "about_page", + "field": "updated_by", + "related_collection": "directus_users", + "meta": { + "junction_field": null, + "many_collection": "about_page", + "many_field": "updated_by", + "one_allowed_collections": null, + "one_collection": "directus_users", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "about_page", + "column": "updated_by", + "foreign_key_table": "directus_users", + "foreign_key_column": "id", + "constraint_name": "about_page_updated_by_foreign", + "on_update": "NO ACTION", + "on_delete": "NO ACTION" + } + }, + { + "collection": "about_page", + "field": "cover_image", + "related_collection": "directus_files", + "meta": { + "junction_field": null, + "many_collection": "about_page", + "many_field": "cover_image", + "one_allowed_collections": null, + "one_collection": "directus_files", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "about_page", + "column": "cover_image", + "foreign_key_table": "directus_files", + "foreign_key_column": "id", + "constraint_name": "about_page_cover_image_foreign", + "on_update": "NO ACTION", + "on_delete": "SET NULL" + } + }, + { + "collection": "badges", + "field": "user_created", + "related_collection": "directus_users", + "meta": { + "junction_field": null, + "many_collection": "badges", + "many_field": "user_created", + "one_allowed_collections": null, + "one_collection": "directus_users", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null }, "schema": { "table": "badges", @@ -32820,6 +34068,81 @@ "on_update": "NO ACTION", "on_delete": "SET NULL" } + }, + { + "collection": "podcast_generated_content", + "field": "podcast_id", + "related_collection": "podcasts", + "meta": { + "junction_field": null, + "many_collection": "podcast_generated_content", + "many_field": "podcast_id", + "one_allowed_collections": null, + "one_collection": "podcasts", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "podcast_generated_content", + "column": "podcast_id", + "foreign_key_table": "podcasts", + "foreign_key_column": "id", + "on_update": "NO ACTION", + "on_delete": "SET NULL", + "constraint_name": null + } + }, + { + "collection": "podcast_generated_content", + "field": "approved_by", + "related_collection": "directus_users", + "meta": { + "junction_field": null, + "many_collection": "podcast_generated_content", + "many_field": "approved_by", + "one_allowed_collections": null, + "one_collection": "directus_users", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "podcast_generated_content", + "column": "approved_by", + "foreign_key_table": "directus_users", + "foreign_key_column": "id", + "on_update": "NO ACTION", + "on_delete": "SET NULL", + "constraint_name": null + } + }, + { + "collection": "podcasts", + "field": "heise_document", + "related_collection": "directus_files", + "meta": { + "junction_field": null, + "many_collection": "podcasts", + "many_field": "heise_document", + "one_allowed_collections": null, + "one_collection": "directus_files", + "one_collection_field": null, + "one_deselect_action": "nullify", + "one_field": null, + "sort_field": null + }, + "schema": { + "table": "podcasts", + "column": "heise_document", + "foreign_key_table": "directus_files", + "foreign_key_column": "id", + "on_update": "NO ACTION", + "on_delete": "SET NULL", + "constraint_name": null + } } ] } \ No newline at end of file diff --git a/directus-cms/utils/add-automation-fields.mjs b/directus-cms/utils/add-automation-fields.mjs new file mode 100644 index 0000000..2398903 --- /dev/null +++ b/directus-cms/utils/add-automation-fields.mjs @@ -0,0 +1,602 @@ +#!/usr/bin/env node +/** + * Migration script to add podcast automation fields to the Directus schema. + * + * This script adds: + * - Planning fields to podcasts collection (Phase 1.1) + * - Speaker portal fields (Phase 1.4) + * - podcast_generated_content collection (Phase 3.3) + * - Heise fields to podcasts collection (Phase 7.1) + * + * Run with: node utils/add-automation-fields.mjs + * After running, snapshot the schema: npm run snapshot-schema + */ + +const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055'; +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@programmier.bar'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '123456'; + +async function getAuthToken() { + const response = await fetch(`${DIRECTUS_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }) + }); + + if (!response.ok) { + throw new Error(`Failed to authenticate: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return data.data.access_token; +} + +async function fieldExists(token, collection, field) { + const response = await fetch(`${DIRECTUS_URL}/fields/${collection}/${field}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + return response.ok; +} + +async function createField(token, collection, field) { + const exists = await fieldExists(token, collection, field.field); + if (exists) { + console.log(` Field ${collection}.${field.field} already exists, skipping`); + return; + } + + const response = await fetch(`${DIRECTUS_URL}/fields/${collection}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(field) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to create field ${collection}.${field.field}: ${error}`); + } + + console.log(` Created field ${collection}.${field.field}`); +} + +async function collectionExists(token, collection) { + const response = await fetch(`${DIRECTUS_URL}/collections/${collection}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + return response.ok; +} + +async function createCollection(token, collection) { + const exists = await collectionExists(token, collection.collection); + if (exists) { + console.log(` Collection ${collection.collection} already exists, skipping`); + return; + } + + const response = await fetch(`${DIRECTUS_URL}/collections`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(collection) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to create collection ${collection.collection}: ${error}`); + } + + console.log(` Created collection ${collection.collection}`); +} + +async function addPodcastPlanningFields(token) { + console.log('\n=== Adding Planning Fields to Podcasts (Phase 1.1) ==='); + + // Recording date field + await createField(token, 'podcasts', { + field: 'recording_date', + type: 'timestamp', + meta: { + interface: 'datetime', + display: 'datetime', + display_options: { relative: false }, + note: 'When the episode will be/was recorded', + sort: 100, + width: 'half' + }, + schema: { + is_nullable: true + } + }); + + // Planned publish date field + await createField(token, 'podcasts', { + field: 'planned_publish_date', + type: 'timestamp', + meta: { + interface: 'datetime', + display: 'datetime', + display_options: { relative: false }, + note: 'Target publication date', + sort: 101, + width: 'half' + }, + schema: { + is_nullable: true + } + }); + + // Publishing status field + await createField(token, 'podcasts', { + field: 'publishing_status', + type: 'string', + meta: { + interface: 'select-dropdown', + display: 'labels', + display_options: { + showAsDot: true, + choices: [ + { value: 'planned', background: '#6B7280', foreground: '#FFFFFF' }, + { value: 'recorded', background: '#3B82F6', foreground: '#FFFFFF' }, + { value: 'transcribing', background: '#F59E0B', foreground: '#FFFFFF' }, + { value: 'transcript_ready', background: '#8B5CF6', foreground: '#FFFFFF' }, + { value: 'content_review', background: '#EC4899', foreground: '#FFFFFF' }, + { value: 'approved', background: '#10B981', foreground: '#FFFFFF' }, + { value: 'published', background: '#00C897', foreground: '#FFFFFF' } + ] + }, + options: { + choices: [ + { text: 'Planned', value: 'planned' }, + { text: 'Recorded', value: 'recorded' }, + { text: 'Transcribing', value: 'transcribing' }, + { text: 'Transcript Ready', value: 'transcript_ready' }, + { text: 'Content Review', value: 'content_review' }, + { text: 'Approved', value: 'approved' }, + { text: 'Published', value: 'published' } + ] + }, + note: 'Workflow status for the publishing pipeline', + sort: 102, + width: 'half' + }, + schema: { + default_value: 'planned', + is_nullable: true + } + }); +} + +async function addSpeakerPortalFields(token) { + console.log('\n=== Adding Speaker Portal Fields (Phase 1.4) ==='); + + // Portal token + await createField(token, 'speakers', { + field: 'portal_token', + type: 'string', + meta: { + interface: 'input', + display: 'raw', + note: 'Unique token for self-service portal access', + sort: 100, + width: 'half', + hidden: true + }, + schema: { + is_nullable: true, + is_unique: true + } + }); + + // Portal token expires + await createField(token, 'speakers', { + field: 'portal_token_expires', + type: 'timestamp', + meta: { + interface: 'datetime', + display: 'datetime', + display_options: { relative: true }, + note: 'Token expiration date', + sort: 101, + width: 'half', + hidden: true + }, + schema: { + is_nullable: true + } + }); + + // Portal submission status + await createField(token, 'speakers', { + field: 'portal_submission_status', + type: 'string', + meta: { + interface: 'select-dropdown', + display: 'labels', + display_options: { + showAsDot: true, + choices: [ + { value: 'pending', background: '#F59E0B', foreground: '#FFFFFF' }, + { value: 'submitted', background: '#3B82F6', foreground: '#FFFFFF' }, + { value: 'approved', background: '#10B981', foreground: '#FFFFFF' } + ] + }, + options: { + choices: [ + { text: 'Pending', value: 'pending' }, + { text: 'Submitted', value: 'submitted' }, + { text: 'Approved', value: 'approved' } + ] + }, + note: 'Speaker portal submission status', + sort: 102, + width: 'half' + }, + schema: { + is_nullable: true + } + }); + + // Portal submission deadline + await createField(token, 'speakers', { + field: 'portal_submission_deadline', + type: 'timestamp', + meta: { + interface: 'datetime', + display: 'datetime', + display_options: { relative: true }, + note: 'Deadline for speaker to submit info', + sort: 103, + width: 'half' + }, + schema: { + is_nullable: true + } + }); +} + +async function createPodcastGeneratedContentCollection(token) { + console.log('\n=== Creating podcast_generated_content Collection (Phase 3.3) ==='); + + // Create the collection + await createCollection(token, { + collection: 'podcast_generated_content', + meta: { + icon: 'auto_awesome', + note: 'AI-generated content for podcast episodes', + group: 'Podcasts', + sort: 5, + accountability: 'all', + archive_field: 'status', + archive_value: 'archived', + unarchive_value: 'draft' + }, + schema: { + name: 'podcast_generated_content' + } + }); + + // Add fields + await createField(token, 'podcast_generated_content', { + field: 'id', + type: 'uuid', + meta: { + interface: 'input', + readonly: true, + hidden: true, + special: ['uuid'] + }, + schema: { + is_primary_key: true, + is_nullable: false + } + }); + + await createField(token, 'podcast_generated_content', { + field: 'podcast_id', + type: 'uuid', + meta: { + interface: 'select-dropdown-m2o', + display: 'related-values', + display_options: { template: '{{title}}' }, + note: 'Related podcast episode', + sort: 1, + width: 'half', + special: ['m2o'] + }, + schema: { + is_nullable: true, + foreign_key_table: 'podcasts', + foreign_key_column: 'id' + } + }); + + await createField(token, 'podcast_generated_content', { + field: 'content_type', + type: 'string', + meta: { + interface: 'select-dropdown', + display: 'labels', + options: { + choices: [ + { text: 'Shownotes', value: 'shownotes' }, + { text: 'LinkedIn', value: 'social_linkedin' }, + { text: 'Instagram', value: 'social_instagram' }, + { text: 'Bluesky', value: 'social_bluesky' }, + { text: 'Mastodon', value: 'social_mastodon' }, + { text: 'Heise Document', value: 'heise_document' } + ] + }, + note: 'Type of generated content', + sort: 2, + width: 'half' + }, + schema: { + is_nullable: false + } + }); + + await createField(token, 'podcast_generated_content', { + field: 'generated_text', + type: 'text', + meta: { + interface: 'input-rich-text-md', + display: 'formatted-value', + note: 'AI-generated content', + sort: 3, + width: 'full' + }, + schema: { + is_nullable: true + } + }); + + await createField(token, 'podcast_generated_content', { + field: 'edited_text', + type: 'text', + meta: { + interface: 'input-rich-text-md', + display: 'formatted-value', + note: 'Human-edited version (if modified)', + sort: 4, + width: 'full' + }, + schema: { + is_nullable: true + } + }); + + await createField(token, 'podcast_generated_content', { + field: 'status', + type: 'string', + meta: { + interface: 'select-dropdown', + display: 'labels', + display_options: { + showAsDot: true, + choices: [ + { value: 'generated', background: '#6B7280', foreground: '#FFFFFF' }, + { value: 'approved', background: '#10B981', foreground: '#FFFFFF' }, + { value: 'rejected', background: '#EF4444', foreground: '#FFFFFF' }, + { value: 'published', background: '#00C897', foreground: '#FFFFFF' } + ] + }, + options: { + choices: [ + { text: 'Generated', value: 'generated' }, + { text: 'Approved', value: 'approved' }, + { text: 'Rejected', value: 'rejected' }, + { text: 'Published', value: 'published' } + ] + }, + note: 'Content approval status', + sort: 5, + width: 'half' + }, + schema: { + default_value: 'generated', + is_nullable: false + } + }); + + await createField(token, 'podcast_generated_content', { + field: 'generated_at', + type: 'timestamp', + meta: { + interface: 'datetime', + display: 'datetime', + display_options: { relative: true }, + note: 'When content was generated', + sort: 6, + width: 'half', + readonly: true + }, + schema: { + is_nullable: true + } + }); + + await createField(token, 'podcast_generated_content', { + field: 'approved_at', + type: 'timestamp', + meta: { + interface: 'datetime', + display: 'datetime', + display_options: { relative: true }, + note: 'When content was approved', + sort: 7, + width: 'half' + }, + schema: { + is_nullable: true + } + }); + + await createField(token, 'podcast_generated_content', { + field: 'approved_by', + type: 'uuid', + meta: { + interface: 'select-dropdown-m2o', + display: 'user', + note: 'User who approved the content', + sort: 8, + width: 'half', + special: ['m2o'] + }, + schema: { + is_nullable: true, + foreign_key_table: 'directus_users', + foreign_key_column: 'id' + } + }); + + await createField(token, 'podcast_generated_content', { + field: 'llm_model', + type: 'string', + meta: { + interface: 'input', + display: 'raw', + note: 'Which LLM model was used', + sort: 9, + width: 'half' + }, + schema: { + is_nullable: true + } + }); + + await createField(token, 'podcast_generated_content', { + field: 'prompt_version', + type: 'string', + meta: { + interface: 'input', + display: 'raw', + note: 'Version of the prompt used', + sort: 10, + width: 'half' + }, + schema: { + is_nullable: true + } + }); +} + +async function addHeiseFields(token) { + console.log('\n=== Adding Heise Fields to Podcasts (Phase 7.1) ==='); + + // Heise eligible + await createField(token, 'podcasts', { + field: 'heise_eligible', + type: 'boolean', + meta: { + interface: 'boolean', + display: 'boolean', + note: 'Whether this episode should go to Heise.de', + sort: 110, + width: 'half' + }, + schema: { + default_value: false, + is_nullable: false + } + }); + + // Heise document status + await createField(token, 'podcasts', { + field: 'heise_document_status', + type: 'string', + meta: { + interface: 'select-dropdown', + display: 'labels', + display_options: { + showAsDot: true, + choices: [ + { value: 'not_applicable', background: '#6B7280', foreground: '#FFFFFF' }, + { value: 'pending', background: '#F59E0B', foreground: '#FFFFFF' }, + { value: 'generated', background: '#3B82F6', foreground: '#FFFFFF' }, + { value: 'approved', background: '#8B5CF6', foreground: '#FFFFFF' }, + { value: 'sent', background: '#10B981', foreground: '#FFFFFF' } + ] + }, + options: { + choices: [ + { text: 'Not Applicable', value: 'not_applicable' }, + { text: 'Pending', value: 'pending' }, + { text: 'Generated', value: 'generated' }, + { text: 'Approved', value: 'approved' }, + { text: 'Sent', value: 'sent' } + ] + }, + note: 'Status of Heise.de document', + sort: 111, + width: 'half' + }, + schema: { + default_value: 'not_applicable', + is_nullable: true + } + }); + + // Heise sent at + await createField(token, 'podcasts', { + field: 'heise_sent_at', + type: 'timestamp', + meta: { + interface: 'datetime', + display: 'datetime', + display_options: { relative: true }, + note: 'When email was sent to Heise.de', + sort: 112, + width: 'half' + }, + schema: { + is_nullable: true + } + }); + + // Heise document (relation to files) + await createField(token, 'podcasts', { + field: 'heise_document', + type: 'uuid', + meta: { + interface: 'file', + display: 'file', + note: 'Generated Heise.de document', + sort: 113, + width: 'half', + special: ['file'] + }, + schema: { + is_nullable: true, + foreign_key_table: 'directus_files', + foreign_key_column: 'id' + } + }); +} + +async function main() { + console.log('=== Podcast Automation Schema Migration ==='); + console.log(`Directus URL: ${DIRECTUS_URL}`); + + try { + const token = await getAuthToken(); + console.log('Authenticated successfully'); + + await addPodcastPlanningFields(token); + await addSpeakerPortalFields(token); + await createPodcastGeneratedContentCollection(token); + await addHeiseFields(token); + + console.log('\n=== Migration Complete ==='); + console.log('Run "npm run snapshot-schema" to update schema.json'); + } catch (error) { + console.error('Migration failed:', error.message); + process.exit(1); + } +} + +main(); diff --git a/directus-cms/utils/cleanup-old-flow.mjs b/directus-cms/utils/cleanup-old-flow.mjs new file mode 100644 index 0000000..1c3740d --- /dev/null +++ b/directus-cms/utils/cleanup-old-flow.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * Utility to delete the old "Process Transcript Upload" flow. + * This flow is no longer needed as content generation is now handled by a Directus hook. + * + * Run with: node utils/cleanup-old-flow.mjs + */ + +const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055'; +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@programmier.bar'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '123456'; + +async function getAuthToken() { + const response = await fetch(`${DIRECTUS_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }) + }); + + if (!response.ok) { + throw new Error(`Failed to authenticate: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return data.data.access_token; +} + +async function deleteFlow(token, flowName) { + // Find the flow + const response = await fetch(`${DIRECTUS_URL}/flows?filter[name][_eq]=${encodeURIComponent(flowName)}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) { + console.log(`Could not find flow "${flowName}"`); + return; + } + + const data = await response.json(); + + if (!data.data || data.data.length === 0) { + console.log(`Flow "${flowName}" not found, nothing to delete`); + return; + } + + const flowId = data.data[0].id; + console.log(`Found flow "${flowName}" with ID: ${flowId}`); + + // Delete the flow + const deleteResponse = await fetch(`${DIRECTUS_URL}/flows/${flowId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (deleteResponse.ok) { + console.log(`Successfully deleted flow "${flowName}"`); + } else { + console.error(`Failed to delete flow: ${deleteResponse.status}`); + } +} + +async function main() { + console.log('=== Cleaning up old flows ==='); + console.log(`Directus URL: ${DIRECTUS_URL}`); + + try { + const token = await getAuthToken(); + console.log('Authenticated successfully\n'); + + await deleteFlow(token, 'Process Transcript Upload'); + + console.log('\n=== Cleanup Complete ==='); + } catch (error) { + console.error('Cleanup failed:', error.message); + process.exit(1); + } +} + +main(); diff --git a/directus-cms/utils/generate-speaker-tokens.mjs b/directus-cms/utils/generate-speaker-tokens.mjs new file mode 100644 index 0000000..e9bb4f2 --- /dev/null +++ b/directus-cms/utils/generate-speaker-tokens.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * Generate portal tokens for existing speakers who don't have one. + * + * Run with: node utils/generate-speaker-tokens.mjs + */ + +import crypto from 'crypto'; + +const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055'; +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@programmier.bar'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '123456'; + +async function getAuthToken() { + const response = await fetch(`${DIRECTUS_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }) + }); + + if (!response.ok) { + throw new Error(`Failed to authenticate: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return data.data.access_token; +} + +async function main() { + console.log('=== Generate Speaker Portal Tokens ===\n'); + console.log(`Directus URL: ${DIRECTUS_URL}\n`); + + try { + const token = await getAuthToken(); + console.log('Authenticated successfully\n'); + + // Get all speakers without a portal token + const speakersResponse = await fetch( + `${DIRECTUS_URL}/items/speakers?filter[portal_token][_null]=true&fields=id,first_name,last_name,slug`, + { headers: { 'Authorization': `Bearer ${token}` } } + ); + + if (!speakersResponse.ok) { + const errorText = await speakersResponse.text(); + throw new Error(`Failed to fetch speakers: ${speakersResponse.status} - ${errorText}`); + } + + const speakersData = await speakersResponse.json(); + const speakers = speakersData.data || []; + + if (speakers.length === 0) { + console.log('All speakers already have portal tokens!'); + return; + } + + console.log(`Found ${speakers.length} speaker(s) without tokens:\n`); + + for (const speaker of speakers) { + const portalToken = crypto.randomUUID(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 14); // 14 days from now + const displayName = `${speaker.first_name} ${speaker.last_name}`.trim() || speaker.slug; + + const updateResponse = await fetch(`${DIRECTUS_URL}/items/speakers/${speaker.id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + portal_token: portalToken, + portal_token_expires: expiresAt.toISOString(), + portal_submission_status: 'pending' + }) + }); + + if (updateResponse.ok) { + console.log(`✓ ${displayName}`); + console.log(` Token: ${portalToken}`); + console.log(` Expires: ${expiresAt.toISOString()}\n`); + } else { + console.log(`✗ Failed to update ${displayName}`); + } + } + + console.log('=== Done ==='); + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } +} + +main(); diff --git a/directus-cms/utils/setup-flows.mjs b/directus-cms/utils/setup-flows.mjs new file mode 100644 index 0000000..ddbfc7d --- /dev/null +++ b/directus-cms/utils/setup-flows.mjs @@ -0,0 +1,349 @@ +#!/usr/bin/env node +/** + * Script to set up Directus Flows for podcast automation. + * + * This script creates: + * - Speaker token generation flow (Phase 5.2) + * - Calendar view presets (Phase 1.2, 1.3) + * + * Run with: node utils/setup-flows.mjs + */ + +const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055'; +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@programmier.bar'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '123456'; + +async function getAuthToken() { + const response = await fetch(`${DIRECTUS_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }) + }); + + if (!response.ok) { + throw new Error(`Failed to authenticate: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return data.data.access_token; +} + +async function flowExists(token, name) { + const response = await fetch(`${DIRECTUS_URL}/flows?filter[name][_eq]=${encodeURIComponent(name)}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!response.ok) return false; + const data = await response.json(); + return data.data && data.data.length > 0; +} + +async function createSpeakerTokenGenerationFlow(token) { + console.log('\n=== Creating Speaker Token Generation Flow ==='); + + const flowName = 'Generate Speaker Portal Token'; + + if (await flowExists(token, flowName)) { + console.log(' Flow already exists, skipping'); + return; + } + + // Create the flow + const flowResponse = await fetch(`${DIRECTUS_URL}/flows`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: flowName, + icon: 'key', + color: '#10B981', + description: 'Automatically generates a portal token when a new speaker is created', + status: 'active', + trigger: 'event', + accountability: 'all', + options: { + type: 'filter', + scope: ['items.create'], + collections: ['speakers'] + } + }) + }); + + if (!flowResponse.ok) { + const error = await flowResponse.text(); + console.error(' Failed to create flow:', error); + return; + } + + const flow = await flowResponse.json(); + const flowId = flow.data.id; + console.log(' Created flow:', flowId); + + // Create operation: Generate UUID token + const generateTokenOp = await fetch(`${DIRECTUS_URL}/operations`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: 'Generate Token', + key: 'generate_token', + type: 'exec', + position_x: 19, + position_y: 1, + flow: flowId, + options: { + code: ` +module.exports = async function(data) { + const crypto = require('crypto'); + const token = crypto.randomUUID(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 14); // 14 days from now + return { + portal_token: token, + portal_token_expires: expiresAt.toISOString(), + portal_submission_status: 'pending' + }; +} +` + } + }) + }); + + if (!generateTokenOp.ok) { + console.error(' Failed to create generate token operation'); + return; + } + + const tokenOp = await generateTokenOp.json(); + console.log(' Created generate token operation'); + + // Create operation: Update speaker with token + const updateSpeakerOp = await fetch(`${DIRECTUS_URL}/operations`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: 'Update Speaker', + key: 'update_speaker', + type: 'item-update', + position_x: 37, + position_y: 1, + flow: flowId, + options: { + collection: 'speakers', + key: '{{$trigger.keys[0]}}', + payload: { + portal_token: '{{generate_token.portal_token}}', + portal_token_expires: '{{generate_token.portal_token_expires}}', + portal_submission_status: '{{generate_token.portal_submission_status}}' + } + } + }) + }); + + if (!updateSpeakerOp.ok) { + console.error(' Failed to create update speaker operation'); + return; + } + + const updateOp = await updateSpeakerOp.json(); + console.log(' Created update speaker operation'); + + // Link operations + await fetch(`${DIRECTUS_URL}/operations/${tokenOp.data.id}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + resolve: updateOp.data.id + }) + }); + + // Set first operation on flow + await fetch(`${DIRECTUS_URL}/flows/${flowId}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + operation: tokenOp.data.id + }) + }); + + console.log(' Flow setup complete!'); +} + +async function presetExists(token, collection, bookmark) { + const response = await fetch(`${DIRECTUS_URL}/presets?filter[collection][_eq]=${collection}&filter[bookmark][_eq]=${encodeURIComponent(bookmark)}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!response.ok) return false; + const data = await response.json(); + return data.data && data.data.length > 0; +} + +async function createCalendarViewPresets(token) { + console.log('\n=== Creating Calendar View Presets ==='); + + // Recording Calendar View + const recordingPresetName = 'Recording Calendar'; + if (await presetExists(token, 'podcasts', recordingPresetName)) { + console.log(' Recording calendar preset already exists, skipping'); + } else { + const recordingResponse = await fetch(`${DIRECTUS_URL}/presets`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + bookmark: recordingPresetName, + collection: 'podcasts', + layout: 'calendar', + layout_query: { + calendar: { + date_field: 'recording_date', + view_type: 'month' + } + }, + layout_options: { + calendar: { + date_field: 'recording_date', + view_type: 'month', + first_day: 1, + template: '{{title}}' + } + }, + filter: null, + icon: 'calendar_month', + color: '#3B82F6' + }) + }); + + if (recordingResponse.ok) { + console.log(' Created Recording Calendar preset'); + } else { + console.error(' Failed to create Recording Calendar preset'); + } + } + + // Publishing Calendar View + const publishingPresetName = 'Publishing Calendar'; + if (await presetExists(token, 'podcasts', publishingPresetName)) { + console.log(' Publishing calendar preset already exists, skipping'); + } else { + const publishingResponse = await fetch(`${DIRECTUS_URL}/presets`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + bookmark: publishingPresetName, + collection: 'podcasts', + layout: 'calendar', + layout_query: { + calendar: { + date_field: 'planned_publish_date', + view_type: 'month' + } + }, + layout_options: { + calendar: { + date_field: 'planned_publish_date', + view_type: 'month', + first_day: 1, + template: '{{title}}' + } + }, + filter: { + _and: [ + { + publishing_status: { + _in: ['approved', 'content_review', 'transcript_ready', 'transcribing', 'recorded', 'planned'] + } + } + ] + }, + icon: 'event', + color: '#10B981' + }) + }); + + if (publishingResponse.ok) { + console.log(' Created Publishing Calendar preset'); + } else { + console.error(' Failed to create Publishing Calendar preset'); + } + } + + // Approved Episodes (ready to publish) + const approvedPresetName = 'Ready to Publish'; + if (await presetExists(token, 'podcasts', approvedPresetName)) { + console.log(' Ready to Publish preset already exists, skipping'); + } else { + const approvedResponse = await fetch(`${DIRECTUS_URL}/presets`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + bookmark: approvedPresetName, + collection: 'podcasts', + layout: 'tabular', + layout_query: { + tabular: { + fields: ['title', 'type', 'planned_publish_date', 'publishing_status'] + } + }, + filter: { + publishing_status: { + _eq: 'approved' + } + }, + icon: 'check_circle', + color: '#10B981' + }) + }); + + if (approvedResponse.ok) { + console.log(' Created Ready to Publish preset'); + } else { + console.error(' Failed to create Ready to Publish preset'); + } + } +} + +// Note: Content generation is now handled by the content-generation Directus hook +// The "Process Transcript Upload" flow is no longer needed + +async function main() { + console.log('=== Setting up Directus Flows and Presets ==='); + console.log(`Directus URL: ${DIRECTUS_URL}`); + + try { + const token = await getAuthToken(); + console.log('Authenticated successfully'); + + await createSpeakerTokenGenerationFlow(token); + await createCalendarViewPresets(token); + + console.log('\n=== Setup Complete ==='); + console.log('\nNote: Content generation is handled by the content-generation Directus hook.'); + console.log('Make sure GEMINI_API_KEY is set in your Directus environment.'); + } catch (error) { + console.error('Setup failed:', error.message); + process.exit(1); + } +} + +main(); diff --git a/directus-cms/utils/setup-generated-content-relation.mjs b/directus-cms/utils/setup-generated-content-relation.mjs new file mode 100644 index 0000000..27c332e --- /dev/null +++ b/directus-cms/utils/setup-generated-content-relation.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * Sets up the O2M relation from podcasts to podcast_generated_content. + * This allows viewing and editing generated content directly from the podcast edit page. + * + * Run with: node utils/setup-generated-content-relation.mjs + */ + +const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055' +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@programmier.bar' +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '123456' + +async function getAuthToken() { + const response = await fetch(`${DIRECTUS_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + }) + + if (!response.ok) { + throw new Error(`Failed to authenticate: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + return data.data.access_token +} + +async function checkFieldExists(token, collection, field) { + const response = await fetch(`${DIRECTUS_URL}/fields/${collection}/${field}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + return response.ok +} + +async function createGeneratedContentRelation(token) { + // Check if the field already exists + const exists = await checkFieldExists(token, 'podcasts', 'generated_content') + if (exists) { + console.log('Field "generated_content" already exists on podcasts collection') + return + } + + console.log('Creating O2M relation field "generated_content" on podcasts collection...') + + // Create the O2M field on podcasts + const response = await fetch(`${DIRECTUS_URL}/fields/podcasts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + field: 'generated_content', + type: 'alias', + meta: { + interface: 'list-o2m', + special: ['o2m'], + display: 'related-values', + display_options: { + template: '{{content_type}} - {{status}}', + }, + options: { + enableCreate: false, + enableSelect: false, + layout: 'table', + fields: ['content_type', 'status', 'generated_at', 'generated_text'], + }, + width: 'full', + group: null, + hidden: false, + note: 'AI-generated content for this podcast (shownotes, social posts)', + }, + }), + }) + + if (!response.ok) { + const error = await response.text() + console.error('Failed to create field:', error) + return + } + + console.log('Field created successfully') + + // Now update the relation to link the two + console.log('Updating relation...') + + const relationResponse = await fetch(`${DIRECTUS_URL}/relations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + collection: 'podcast_generated_content', + field: 'podcast_id', + related_collection: 'podcasts', + meta: { + one_field: 'generated_content', + sort_field: null, + one_deselect_action: 'nullify', + }, + }), + }) + + if (!relationResponse.ok) { + // Relation might already exist, try to update it instead + const updateResponse = await fetch( + `${DIRECTUS_URL}/relations/podcast_generated_content/podcast_id`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + meta: { + one_field: 'generated_content', + }, + }), + } + ) + + if (updateResponse.ok) { + console.log('Relation updated successfully') + } else { + const error = await updateResponse.text() + console.error('Failed to update relation:', error) + } + } else { + console.log('Relation created successfully') + } +} + +async function main() { + console.log('=== Setting up Generated Content Relation ===') + console.log(`Directus URL: ${DIRECTUS_URL}`) + + try { + const token = await getAuthToken() + console.log('Authenticated successfully\n') + + await createGeneratedContentRelation(token) + + console.log('\n=== Setup Complete ===') + console.log('You can now see generated content in the podcast edit page in Directus.') + } catch (error) { + console.error('Setup failed:', error.message) + process.exit(1) + } +} + +main() diff --git a/directus-cms/utils/setup-social-media-posts.mjs b/directus-cms/utils/setup-social-media-posts.mjs new file mode 100644 index 0000000..f4dd088 --- /dev/null +++ b/directus-cms/utils/setup-social-media-posts.mjs @@ -0,0 +1,430 @@ +#!/usr/bin/env node +/** + * Sets up the social_media_posts collection for scheduling and tracking social media posts. + * + * Run with: node utils/setup-social-media-posts.mjs + */ + +const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055' +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@programmier.bar' +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '123456' + +async function getAuthToken() { + const response = await fetch(`${DIRECTUS_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + }) + + if (!response.ok) { + throw new Error(`Failed to authenticate: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + return data.data.access_token +} + +async function checkCollectionExists(token, collection) { + const response = await fetch(`${DIRECTUS_URL}/collections/${collection}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + return response.ok +} + +async function createSocialMediaPostsCollection(token) { + // Check if collection already exists + const exists = await checkCollectionExists(token, 'social_media_posts') + if (exists) { + console.log('Collection "social_media_posts" already exists') + return + } + + console.log('Creating social_media_posts collection...') + + // Create the collection + const collectionResponse = await fetch(`${DIRECTUS_URL}/collections`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + collection: 'social_media_posts', + meta: { + collection: 'social_media_posts', + icon: 'share', + note: 'Scheduled social media posts for podcast episodes', + display_template: '{{platform}} - {{status}}', + hidden: false, + singleton: false, + archive_field: 'status', + archive_value: 'archived', + sort_field: 'scheduled_for', + }, + schema: { + name: 'social_media_posts', + }, + fields: [ + { + field: 'id', + type: 'integer', + meta: { + hidden: true, + readonly: true, + interface: 'input', + special: null, + }, + schema: { + is_primary_key: true, + has_auto_increment: true, + }, + }, + ], + }), + }) + + if (!collectionResponse.ok) { + const error = await collectionResponse.text() + throw new Error(`Failed to create collection: ${error}`) + } + + console.log('Collection created successfully') + + // Add fields + const fields = [ + { + field: 'podcast_id', + type: 'integer', + meta: { + interface: 'select-dropdown-m2o', + special: ['m2o'], + display: 'related-values', + display_options: { template: '{{title}}' }, + note: 'The podcast episode this post is for', + width: 'half', + }, + schema: {}, + }, + { + field: 'generated_content_id', + type: 'integer', + meta: { + interface: 'select-dropdown-m2o', + special: ['m2o'], + display: 'related-values', + display_options: { template: '{{content_type}}' }, + note: 'Link to the AI-generated content', + width: 'half', + }, + schema: {}, + }, + { + field: 'platform', + type: 'string', + meta: { + interface: 'select-dropdown', + display: 'labels', + display_options: { + choices: [ + { text: 'LinkedIn', value: 'linkedin', foreground: '#FFFFFF', background: '#0A66C2' }, + { text: 'Instagram', value: 'instagram', foreground: '#FFFFFF', background: '#E4405F' }, + { text: 'Bluesky', value: 'bluesky', foreground: '#FFFFFF', background: '#0085FF' }, + { text: 'Mastodon', value: 'mastodon', foreground: '#FFFFFF', background: '#6364FF' }, + ], + }, + options: { + choices: [ + { text: 'LinkedIn', value: 'linkedin' }, + { text: 'Instagram', value: 'instagram' }, + { text: 'Bluesky', value: 'bluesky' }, + { text: 'Mastodon', value: 'mastodon' }, + ], + }, + width: 'half', + required: true, + }, + schema: { + is_nullable: false, + }, + }, + { + field: 'status', + type: 'string', + meta: { + interface: 'select-dropdown', + display: 'labels', + display_options: { + choices: [ + { text: 'Draft', value: 'draft', foreground: '#FFFFFF', background: '#6B7280' }, + { text: 'Scheduled', value: 'scheduled', foreground: '#000000', background: '#FCD34D' }, + { text: 'Publishing', value: 'publishing', foreground: '#FFFFFF', background: '#3B82F6' }, + { text: 'Published', value: 'published', foreground: '#FFFFFF', background: '#10B981' }, + { text: 'Failed', value: 'failed', foreground: '#FFFFFF', background: '#EF4444' }, + ], + }, + options: { + choices: [ + { text: 'Draft', value: 'draft' }, + { text: 'Scheduled', value: 'scheduled' }, + { text: 'Publishing', value: 'publishing' }, + { text: 'Published', value: 'published' }, + { text: 'Failed', value: 'failed' }, + ], + }, + width: 'half', + default_value: 'draft', + }, + schema: { + default_value: 'draft', + }, + }, + { + field: 'post_text', + type: 'text', + meta: { + interface: 'input-multiline', + display: 'formatted-value', + note: 'The text content of the post', + width: 'full', + }, + schema: {}, + }, + { + field: 'scheduled_for', + type: 'timestamp', + meta: { + interface: 'datetime', + display: 'datetime', + note: 'When to publish the post', + width: 'half', + }, + schema: {}, + }, + { + field: 'published_at', + type: 'timestamp', + meta: { + interface: 'datetime', + display: 'datetime', + note: 'When the post was actually published', + width: 'half', + readonly: true, + }, + schema: {}, + }, + { + field: 'platform_post_id', + type: 'string', + meta: { + interface: 'input', + note: 'ID returned by the platform after publishing', + width: 'half', + readonly: true, + }, + schema: {}, + }, + { + field: 'platform_post_url', + type: 'string', + meta: { + interface: 'input', + display: 'formatted-value', + display_options: { format: true }, + note: 'URL to the published post', + width: 'half', + readonly: true, + }, + schema: {}, + }, + { + field: 'error_message', + type: 'text', + meta: { + interface: 'input-multiline', + note: 'Error message if publishing failed', + width: 'full', + hidden: false, + readonly: true, + }, + schema: {}, + }, + { + field: 'tags', + type: 'json', + meta: { + interface: 'input-code', + options: { language: 'json' }, + note: 'People/companies to tag (platform-specific identifiers)', + width: 'full', + }, + schema: {}, + }, + { + field: 'date_created', + type: 'timestamp', + meta: { + interface: 'datetime', + display: 'datetime', + readonly: true, + hidden: true, + special: ['date-created'], + }, + schema: {}, + }, + { + field: 'date_updated', + type: 'timestamp', + meta: { + interface: 'datetime', + display: 'datetime', + readonly: true, + hidden: true, + special: ['date-updated'], + }, + schema: {}, + }, + ] + + for (const field of fields) { + console.log(`Creating field: ${field.field}`) + const fieldResponse = await fetch(`${DIRECTUS_URL}/fields/social_media_posts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(field), + }) + + if (!fieldResponse.ok) { + const error = await fieldResponse.text() + console.warn(`Warning: Failed to create field ${field.field}: ${error}`) + } + } + + console.log('Fields created successfully') +} + +async function createRelations(token) { + console.log('\nSetting up relations...') + + // Relation to podcasts + const podcastRelation = await fetch(`${DIRECTUS_URL}/relations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + collection: 'social_media_posts', + field: 'podcast_id', + related_collection: 'podcasts', + meta: { + one_field: 'social_media_posts', + sort_field: null, + one_deselect_action: 'nullify', + }, + }), + }) + + if (podcastRelation.ok) { + console.log('Podcast relation created') + } else { + console.warn('Podcast relation may already exist or failed') + } + + // Relation to podcast_generated_content + const contentRelation = await fetch(`${DIRECTUS_URL}/relations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + collection: 'social_media_posts', + field: 'generated_content_id', + related_collection: 'podcast_generated_content', + meta: { + sort_field: null, + one_deselect_action: 'nullify', + }, + }), + }) + + if (contentRelation.ok) { + console.log('Generated content relation created') + } else { + console.warn('Generated content relation may already exist or failed') + } +} + +async function addO2MFieldToPodcasts(token) { + console.log('\nAdding social_media_posts field to podcasts collection...') + + const response = await fetch(`${DIRECTUS_URL}/fields/podcasts/social_media_posts`, { + headers: { Authorization: `Bearer ${token}` }, + }) + + if (response.ok) { + console.log('Field already exists on podcasts') + return + } + + const fieldResponse = await fetch(`${DIRECTUS_URL}/fields/podcasts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + field: 'social_media_posts', + type: 'alias', + meta: { + interface: 'list-o2m', + special: ['o2m'], + display: 'related-values', + display_options: { + template: '{{platform}} - {{status}}', + }, + options: { + enableCreate: true, + enableSelect: false, + layout: 'table', + fields: ['platform', 'status', 'scheduled_for', 'post_text'], + }, + width: 'full', + note: 'Social media posts scheduled for this episode', + }, + }), + }) + + if (fieldResponse.ok) { + console.log('O2M field added to podcasts') + } else { + const error = await fieldResponse.text() + console.warn('Failed to add O2M field:', error) + } +} + +async function main() { + console.log('=== Setting up Social Media Posts Collection ===') + console.log(`Directus URL: ${DIRECTUS_URL}\n`) + + try { + const token = await getAuthToken() + console.log('Authenticated successfully\n') + + await createSocialMediaPostsCollection(token) + await createRelations(token) + await addO2MFieldToPodcasts(token) + + console.log('\n=== Setup Complete ===') + console.log('The social_media_posts collection is ready.') + console.log('You can now schedule and track social media posts for podcast episodes.') + } catch (error) { + console.error('Setup failed:', error.message) + process.exit(1) + } +} + +main() diff --git a/directus-cms/utils/update-schema-json.mjs b/directus-cms/utils/update-schema-json.mjs new file mode 100644 index 0000000..b60fe0d --- /dev/null +++ b/directus-cms/utils/update-schema-json.mjs @@ -0,0 +1,1297 @@ +#!/usr/bin/env node +/** + * Script to update schema.json with podcast automation fields. + * This maintains the production PostgreSQL format while adding new fields. + * + * Run with: node utils/update-schema-json.mjs + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const schemaPath = join(__dirname, '..', 'schema.json'); + +// Read existing schema +const schema = JSON.parse(readFileSync(schemaPath, 'utf-8')); + +// Helper to find max sort value in a collection +function getMaxSort(fields, collection) { + return fields + .filter(f => f.collection === collection) + .reduce((max, f) => Math.max(max, f.meta?.sort || 0), 0); +} + +// Helper to check if field exists +function fieldExists(fields, collection, fieldName) { + return fields.some(f => f.collection === collection && f.field === fieldName); +} + +// Helper to check if collection exists +function collectionExists(collections, name) { + return collections.some(c => c.collection === name); +} + +// Add podcast planning fields (Phase 1.1) +function addPodcastPlanningFields(schema) { + console.log('Adding podcast planning fields...'); + let sort = getMaxSort(schema.fields, 'podcasts') + 1; + + const planningFields = [ + { + collection: 'podcasts', + field: 'recording_date', + type: 'timestamp', + meta: { + collection: 'podcasts', + conditions: null, + display: 'datetime', + display_options: { relative: false }, + field: 'recording_date', + group: null, + hidden: false, + interface: 'datetime', + note: 'When the episode will be/was recorded', + options: null, + readonly: false, + required: false, + searchable: true, + sort: sort++, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'recording_date', + table: 'podcasts', + data_type: 'timestamp with time zone', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcasts', + field: 'planned_publish_date', + type: 'timestamp', + meta: { + collection: 'podcasts', + conditions: null, + display: 'datetime', + display_options: { relative: false }, + field: 'planned_publish_date', + group: null, + hidden: false, + interface: 'datetime', + note: 'Target publication date', + options: null, + readonly: false, + required: false, + searchable: true, + sort: sort++, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'planned_publish_date', + table: 'podcasts', + data_type: 'timestamp with time zone', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcasts', + field: 'publishing_status', + type: 'string', + meta: { + collection: 'podcasts', + conditions: null, + display: 'labels', + display_options: { + showAsDot: true, + choices: [ + { value: 'planned', background: '#6B7280', foreground: '#FFFFFF' }, + { value: 'recorded', background: '#3B82F6', foreground: '#FFFFFF' }, + { value: 'transcribing', background: '#F59E0B', foreground: '#FFFFFF' }, + { value: 'transcript_ready', background: '#8B5CF6', foreground: '#FFFFFF' }, + { value: 'content_review', background: '#EC4899', foreground: '#FFFFFF' }, + { value: 'approved', background: '#10B981', foreground: '#FFFFFF' }, + { value: 'published', background: '#00C897', foreground: '#FFFFFF' } + ] + }, + field: 'publishing_status', + group: null, + hidden: false, + interface: 'select-dropdown', + note: 'Workflow status for the publishing pipeline', + options: { + choices: [ + { text: 'Planned', value: 'planned' }, + { text: 'Recorded', value: 'recorded' }, + { text: 'Transcribing', value: 'transcribing' }, + { text: 'Transcript Ready', value: 'transcript_ready' }, + { text: 'Content Review', value: 'content_review' }, + { text: 'Approved', value: 'approved' }, + { text: 'Published', value: 'published' } + ] + }, + readonly: false, + required: false, + searchable: true, + sort: sort++, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'publishing_status', + table: 'podcasts', + data_type: 'character varying', + default_value: 'planned', + max_length: 255, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + } + ]; + + for (const field of planningFields) { + if (!fieldExists(schema.fields, field.collection, field.field)) { + schema.fields.push(field); + console.log(` Added ${field.collection}.${field.field}`); + } else { + console.log(` ${field.collection}.${field.field} already exists, skipping`); + } + } +} + +// Add speaker portal fields (Phase 1.4) +function addSpeakerPortalFields(schema) { + console.log('Adding speaker portal fields...'); + let sort = getMaxSort(schema.fields, 'speakers') + 1; + + const portalFields = [ + { + collection: 'speakers', + field: 'portal_token', + type: 'string', + meta: { + collection: 'speakers', + conditions: null, + display: 'raw', + display_options: null, + field: 'portal_token', + group: null, + hidden: true, + interface: 'input', + note: 'Unique token for self-service portal access', + options: null, + readonly: false, + required: false, + searchable: true, + sort: sort++, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'portal_token', + table: 'speakers', + data_type: 'character varying', + default_value: null, + max_length: 255, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: true, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'speakers', + field: 'portal_token_expires', + type: 'timestamp', + meta: { + collection: 'speakers', + conditions: null, + display: 'datetime', + display_options: { relative: true }, + field: 'portal_token_expires', + group: null, + hidden: true, + interface: 'datetime', + note: 'Token expiration date', + options: null, + readonly: false, + required: false, + searchable: true, + sort: sort++, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'portal_token_expires', + table: 'speakers', + data_type: 'timestamp with time zone', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'speakers', + field: 'portal_submission_status', + type: 'string', + meta: { + collection: 'speakers', + conditions: null, + display: 'labels', + display_options: { + showAsDot: true, + choices: [ + { value: 'pending', background: '#F59E0B', foreground: '#FFFFFF' }, + { value: 'submitted', background: '#3B82F6', foreground: '#FFFFFF' }, + { value: 'approved', background: '#10B981', foreground: '#FFFFFF' } + ] + }, + field: 'portal_submission_status', + group: null, + hidden: false, + interface: 'select-dropdown', + note: 'Speaker portal submission status', + options: { + choices: [ + { text: 'Pending', value: 'pending' }, + { text: 'Submitted', value: 'submitted' }, + { text: 'Approved', value: 'approved' } + ] + }, + readonly: false, + required: false, + searchable: true, + sort: sort++, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'portal_submission_status', + table: 'speakers', + data_type: 'character varying', + default_value: null, + max_length: 255, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'speakers', + field: 'portal_submission_deadline', + type: 'timestamp', + meta: { + collection: 'speakers', + conditions: null, + display: 'datetime', + display_options: { relative: true }, + field: 'portal_submission_deadline', + group: null, + hidden: false, + interface: 'datetime', + note: 'Deadline for speaker to submit info', + options: null, + readonly: false, + required: false, + searchable: true, + sort: sort++, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'portal_submission_deadline', + table: 'speakers', + data_type: 'timestamp with time zone', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + } + ]; + + for (const field of portalFields) { + if (!fieldExists(schema.fields, field.collection, field.field)) { + schema.fields.push(field); + console.log(` Added ${field.collection}.${field.field}`); + } else { + console.log(` ${field.collection}.${field.field} already exists, skipping`); + } + } +} + +// Add podcast_generated_content collection (Phase 3.3) +function addPodcastGeneratedContentCollection(schema) { + console.log('Adding podcast_generated_content collection...'); + + // Add collection if not exists + if (!collectionExists(schema.collections, 'podcast_generated_content')) { + schema.collections.push({ + collection: 'podcast_generated_content', + meta: { + accountability: 'all', + archive_app_filter: true, + archive_field: 'status', + archive_value: 'archived', + collapse: 'open', + collection: 'podcast_generated_content', + color: null, + display_template: null, + group: 'Podcasts', + hidden: false, + icon: 'auto_awesome', + item_duplication_fields: null, + note: 'AI-generated content for podcast episodes', + preview_url: null, + singleton: false, + sort: 5, + sort_field: null, + translations: null, + unarchive_value: 'draft', + versioning: false + }, + schema: { + name: 'podcast_generated_content' + } + }); + console.log(' Added collection podcast_generated_content'); + } else { + console.log(' Collection podcast_generated_content already exists, skipping'); + } + + // Add fields + const fields = [ + { + collection: 'podcast_generated_content', + field: 'id', + type: 'uuid', + meta: { + collection: 'podcast_generated_content', + conditions: null, + display: null, + display_options: null, + field: 'id', + group: null, + hidden: true, + interface: 'input', + note: null, + options: null, + readonly: true, + required: false, + searchable: true, + sort: 1, + special: ['uuid'], + translations: null, + validation: null, + validation_message: null, + width: 'full' + }, + schema: { + name: 'id', + table: 'podcast_generated_content', + data_type: 'uuid', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: false, + is_unique: true, + is_indexed: false, + is_primary_key: true, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcast_generated_content', + field: 'podcast_id', + type: 'uuid', + meta: { + collection: 'podcast_generated_content', + conditions: null, + display: 'related-values', + display_options: { template: '{{title}}' }, + field: 'podcast_id', + group: null, + hidden: false, + interface: 'select-dropdown-m2o', + note: 'Related podcast episode', + options: null, + readonly: false, + required: false, + searchable: true, + sort: 2, + special: ['m2o'], + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'podcast_id', + table: 'podcast_generated_content', + data_type: 'uuid', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: 'podcasts', + foreign_key_column: 'id' + } + }, + { + collection: 'podcast_generated_content', + field: 'content_type', + type: 'string', + meta: { + collection: 'podcast_generated_content', + conditions: null, + display: 'labels', + display_options: null, + field: 'content_type', + group: null, + hidden: false, + interface: 'select-dropdown', + note: 'Type of generated content', + options: { + choices: [ + { text: 'Shownotes', value: 'shownotes' }, + { text: 'LinkedIn', value: 'social_linkedin' }, + { text: 'Instagram', value: 'social_instagram' }, + { text: 'Bluesky', value: 'social_bluesky' }, + { text: 'Mastodon', value: 'social_mastodon' }, + { text: 'Heise Document', value: 'heise_document' } + ] + }, + readonly: false, + required: true, + searchable: true, + sort: 3, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'content_type', + table: 'podcast_generated_content', + data_type: 'character varying', + default_value: null, + max_length: 255, + numeric_precision: null, + numeric_scale: null, + is_nullable: false, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcast_generated_content', + field: 'generated_text', + type: 'text', + meta: { + collection: 'podcast_generated_content', + conditions: null, + display: 'formatted-value', + display_options: null, + field: 'generated_text', + group: null, + hidden: false, + interface: 'input-rich-text-md', + note: 'AI-generated content', + options: null, + readonly: false, + required: false, + searchable: true, + sort: 4, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'full' + }, + schema: { + name: 'generated_text', + table: 'podcast_generated_content', + data_type: 'text', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcast_generated_content', + field: 'edited_text', + type: 'text', + meta: { + collection: 'podcast_generated_content', + conditions: null, + display: 'formatted-value', + display_options: null, + field: 'edited_text', + group: null, + hidden: false, + interface: 'input-rich-text-md', + note: 'Human-edited version (if modified)', + options: null, + readonly: false, + required: false, + searchable: true, + sort: 5, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'full' + }, + schema: { + name: 'edited_text', + table: 'podcast_generated_content', + data_type: 'text', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcast_generated_content', + field: 'status', + type: 'string', + meta: { + collection: 'podcast_generated_content', + conditions: null, + display: 'labels', + display_options: { + showAsDot: true, + choices: [ + { value: 'generated', background: '#6B7280', foreground: '#FFFFFF' }, + { value: 'approved', background: '#10B981', foreground: '#FFFFFF' }, + { value: 'rejected', background: '#EF4444', foreground: '#FFFFFF' }, + { value: 'published', background: '#00C897', foreground: '#FFFFFF' } + ] + }, + field: 'status', + group: null, + hidden: false, + interface: 'select-dropdown', + note: 'Content approval status', + options: { + choices: [ + { text: 'Generated', value: 'generated' }, + { text: 'Approved', value: 'approved' }, + { text: 'Rejected', value: 'rejected' }, + { text: 'Published', value: 'published' } + ] + }, + readonly: false, + required: false, + searchable: true, + sort: 6, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'status', + table: 'podcast_generated_content', + data_type: 'character varying', + default_value: 'generated', + max_length: 255, + numeric_precision: null, + numeric_scale: null, + is_nullable: false, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcast_generated_content', + field: 'generated_at', + type: 'timestamp', + meta: { + collection: 'podcast_generated_content', + conditions: null, + display: 'datetime', + display_options: { relative: true }, + field: 'generated_at', + group: null, + hidden: false, + interface: 'datetime', + note: 'When content was generated', + options: null, + readonly: true, + required: false, + searchable: true, + sort: 7, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'generated_at', + table: 'podcast_generated_content', + data_type: 'timestamp with time zone', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcast_generated_content', + field: 'approved_at', + type: 'timestamp', + meta: { + collection: 'podcast_generated_content', + conditions: null, + display: 'datetime', + display_options: { relative: true }, + field: 'approved_at', + group: null, + hidden: false, + interface: 'datetime', + note: 'When content was approved', + options: null, + readonly: false, + required: false, + searchable: true, + sort: 8, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'approved_at', + table: 'podcast_generated_content', + data_type: 'timestamp with time zone', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcast_generated_content', + field: 'approved_by', + type: 'uuid', + meta: { + collection: 'podcast_generated_content', + conditions: null, + display: 'user', + display_options: null, + field: 'approved_by', + group: null, + hidden: false, + interface: 'select-dropdown-m2o', + note: 'User who approved the content', + options: null, + readonly: false, + required: false, + searchable: true, + sort: 9, + special: ['m2o'], + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'approved_by', + table: 'podcast_generated_content', + data_type: 'uuid', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: 'directus_users', + foreign_key_column: 'id' + } + }, + { + collection: 'podcast_generated_content', + field: 'llm_model', + type: 'string', + meta: { + collection: 'podcast_generated_content', + conditions: null, + display: 'raw', + display_options: null, + field: 'llm_model', + group: null, + hidden: false, + interface: 'input', + note: 'Which LLM model was used', + options: null, + readonly: false, + required: false, + searchable: true, + sort: 10, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'llm_model', + table: 'podcast_generated_content', + data_type: 'character varying', + default_value: null, + max_length: 255, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcast_generated_content', + field: 'prompt_version', + type: 'string', + meta: { + collection: 'podcast_generated_content', + conditions: null, + display: 'raw', + display_options: null, + field: 'prompt_version', + group: null, + hidden: false, + interface: 'input', + note: 'Version of the prompt used', + options: null, + readonly: false, + required: false, + searchable: true, + sort: 11, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'prompt_version', + table: 'podcast_generated_content', + data_type: 'character varying', + default_value: null, + max_length: 255, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + } + ]; + + for (const field of fields) { + if (!fieldExists(schema.fields, field.collection, field.field)) { + schema.fields.push(field); + console.log(` Added ${field.collection}.${field.field}`); + } else { + console.log(` ${field.collection}.${field.field} already exists, skipping`); + } + } + + // Add relation for podcast_id + const podcastRelation = { + collection: 'podcast_generated_content', + field: 'podcast_id', + related_collection: 'podcasts', + meta: { + junction_field: null, + many_collection: 'podcast_generated_content', + many_field: 'podcast_id', + one_allowed_collections: null, + one_collection: 'podcasts', + one_collection_field: null, + one_deselect_action: 'nullify', + one_field: null, + sort_field: null + }, + schema: { + table: 'podcast_generated_content', + column: 'podcast_id', + foreign_key_table: 'podcasts', + foreign_key_column: 'id', + on_update: 'NO ACTION', + on_delete: 'SET NULL', + constraint_name: null + } + }; + + if (!schema.relations.some(r => r.collection === 'podcast_generated_content' && r.field === 'podcast_id')) { + schema.relations.push(podcastRelation); + console.log(' Added relation podcast_generated_content.podcast_id -> podcasts'); + } + + // Add relation for approved_by + const approvedByRelation = { + collection: 'podcast_generated_content', + field: 'approved_by', + related_collection: 'directus_users', + meta: { + junction_field: null, + many_collection: 'podcast_generated_content', + many_field: 'approved_by', + one_allowed_collections: null, + one_collection: 'directus_users', + one_collection_field: null, + one_deselect_action: 'nullify', + one_field: null, + sort_field: null + }, + schema: { + table: 'podcast_generated_content', + column: 'approved_by', + foreign_key_table: 'directus_users', + foreign_key_column: 'id', + on_update: 'NO ACTION', + on_delete: 'SET NULL', + constraint_name: null + } + }; + + if (!schema.relations.some(r => r.collection === 'podcast_generated_content' && r.field === 'approved_by')) { + schema.relations.push(approvedByRelation); + console.log(' Added relation podcast_generated_content.approved_by -> directus_users'); + } +} + +// Add Heise fields to podcasts (Phase 7.1) +function addHeiseFields(schema) { + console.log('Adding Heise fields to podcasts...'); + let sort = getMaxSort(schema.fields, 'podcasts') + 1; + + const heiseFields = [ + { + collection: 'podcasts', + field: 'heise_eligible', + type: 'boolean', + meta: { + collection: 'podcasts', + conditions: null, + display: 'boolean', + display_options: null, + field: 'heise_eligible', + group: null, + hidden: false, + interface: 'boolean', + note: 'Whether this episode should go to Heise.de', + options: null, + readonly: false, + required: false, + searchable: true, + sort: sort++, + special: ['cast-boolean'], + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'heise_eligible', + table: 'podcasts', + data_type: 'boolean', + default_value: false, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: false, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcasts', + field: 'heise_document_status', + type: 'string', + meta: { + collection: 'podcasts', + conditions: null, + display: 'labels', + display_options: { + showAsDot: true, + choices: [ + { value: 'not_applicable', background: '#6B7280', foreground: '#FFFFFF' }, + { value: 'pending', background: '#F59E0B', foreground: '#FFFFFF' }, + { value: 'generated', background: '#3B82F6', foreground: '#FFFFFF' }, + { value: 'approved', background: '#8B5CF6', foreground: '#FFFFFF' }, + { value: 'sent', background: '#10B981', foreground: '#FFFFFF' } + ] + }, + field: 'heise_document_status', + group: null, + hidden: false, + interface: 'select-dropdown', + note: 'Status of Heise.de document', + options: { + choices: [ + { text: 'Not Applicable', value: 'not_applicable' }, + { text: 'Pending', value: 'pending' }, + { text: 'Generated', value: 'generated' }, + { text: 'Approved', value: 'approved' }, + { text: 'Sent', value: 'sent' } + ] + }, + readonly: false, + required: false, + searchable: true, + sort: sort++, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'heise_document_status', + table: 'podcasts', + data_type: 'character varying', + default_value: 'not_applicable', + max_length: 255, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcasts', + field: 'heise_sent_at', + type: 'timestamp', + meta: { + collection: 'podcasts', + conditions: null, + display: 'datetime', + display_options: { relative: true }, + field: 'heise_sent_at', + group: null, + hidden: false, + interface: 'datetime', + note: 'When email was sent to Heise.de', + options: null, + readonly: false, + required: false, + searchable: true, + sort: sort++, + special: null, + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'heise_sent_at', + table: 'podcasts', + data_type: 'timestamp with time zone', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: null, + foreign_key_column: null + } + }, + { + collection: 'podcasts', + field: 'heise_document', + type: 'uuid', + meta: { + collection: 'podcasts', + conditions: null, + display: 'file', + display_options: null, + field: 'heise_document', + group: null, + hidden: false, + interface: 'file', + note: 'Generated Heise.de document', + options: null, + readonly: false, + required: false, + searchable: true, + sort: sort++, + special: ['file'], + translations: null, + validation: null, + validation_message: null, + width: 'half' + }, + schema: { + name: 'heise_document', + table: 'podcasts', + data_type: 'uuid', + default_value: null, + max_length: null, + numeric_precision: null, + numeric_scale: null, + is_nullable: true, + is_unique: false, + is_indexed: false, + is_primary_key: false, + is_generated: false, + generation_expression: null, + has_auto_increment: false, + foreign_key_table: 'directus_files', + foreign_key_column: 'id' + } + } + ]; + + for (const field of heiseFields) { + if (!fieldExists(schema.fields, field.collection, field.field)) { + schema.fields.push(field); + console.log(` Added ${field.collection}.${field.field}`); + } else { + console.log(` ${field.collection}.${field.field} already exists, skipping`); + } + } + + // Add relation for heise_document + const heiseDocRelation = { + collection: 'podcasts', + field: 'heise_document', + related_collection: 'directus_files', + meta: { + junction_field: null, + many_collection: 'podcasts', + many_field: 'heise_document', + one_allowed_collections: null, + one_collection: 'directus_files', + one_collection_field: null, + one_deselect_action: 'nullify', + one_field: null, + sort_field: null + }, + schema: { + table: 'podcasts', + column: 'heise_document', + foreign_key_table: 'directus_files', + foreign_key_column: 'id', + on_update: 'NO ACTION', + on_delete: 'SET NULL', + constraint_name: null + } + }; + + if (!schema.relations.some(r => r.collection === 'podcasts' && r.field === 'heise_document')) { + schema.relations.push(heiseDocRelation); + console.log(' Added relation podcasts.heise_document -> directus_files'); + } +} + +// Main function +function main() { + console.log('=== Updating schema.json with podcast automation fields ===\n'); + + addPodcastPlanningFields(schema); + addSpeakerPortalFields(schema); + addPodcastGeneratedContentCollection(schema); + addHeiseFields(schema); + + // Write updated schema + writeFileSync(schemaPath, JSON.stringify(schema, null, 2)); + console.log('\n=== Schema updated successfully ==='); + console.log(`Wrote to: ${schemaPath}`); +} + +main(); diff --git a/nuxt-app/nuxt.config.ts b/nuxt-app/nuxt.config.ts index ebde545..431e759 100644 --- a/nuxt-app/nuxt.config.ts +++ b/nuxt-app/nuxt.config.ts @@ -29,9 +29,12 @@ export default defineNuxtConfig({ runtimeConfig: { emailPassword: '', + directusAdminToken: '', // Set via NUXT_DIRECTUS_ADMIN_TOKEN env var + geminiApiKey: '', // Set via NUXT_GEMINI_API_KEY env var public: { FLAG_SHOW_LOGIN: FLAG_SHOW_LOGIN, DISCORD_INVITE_LINK: DISCORD_INVITE_LINK, + directusCmsUrl: DIRECTUS_CMS_URL, }, }, diff --git a/nuxt-app/pages/speaker-portal.vue b/nuxt-app/pages/speaker-portal.vue new file mode 100644 index 0000000..3705f30 --- /dev/null +++ b/nuxt-app/pages/speaker-portal.vue @@ -0,0 +1,569 @@ +