← back to Norma

app/api/social/ai-compose/route.ts

164 lines

import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/require-role';

const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
function getGeminiUrl(): string {
  if (!GEMINI_API_KEY) {
    throw new Error('[social/ai-compose] GEMINI_API_KEY env var is required');
  }
  return `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}`;
}

// Character limits and tone guidance per platform
const PLATFORM_SPECS: Record<string, { char_limit: number; guidance: string }> = {
  twitter: {
    char_limit: 280,
    guidance:
      'Write a punchy, concise post under 280 characters. No emojis unless they add meaning. Direct and impactful. Leave room for a link if needed.',
  },
  bluesky: {
    char_limit: 300,
    guidance:
      'Write a thoughtful post under 300 characters. Bluesky users appreciate nuance and directness. Minimal emojis.',
  },
  instagram: {
    char_limit: 2200,
    guidance:
      'Write an engaging caption between 150-300 words. Use relevant emojis naturally throughout. Include a clear call to action. End with a blank line followed by hashtags.',
  },
  linkedin: {
    char_limit: 3000,
    guidance:
      'Write a professional post, 100-200 words. Authoritative, policy-focused tone. No hashtags in the body — list 3-5 relevant ones at the end after a blank line. No emojis.',
  },
  facebook: {
    char_limit: 63206,
    guidance:
      'Write a conversational post, 100-200 words. Warm and accessible tone. 1-3 relevant emojis. Include a clear call to action.',
  },
};

/**
 * POST /api/social/ai-compose
 *
 * Body:
 *   topic          string  required — subject matter or action to communicate
 *   platform       string  required — twitter | bluesky | instagram | linkedin | facebook
 *   tone?          string  — urgent | hopeful | informational | celebratory (default: informational)
 *   news_headline? string  — breaking news headline to reference
 *   news_summary?  string  — brief context for the news item
 *   hashtag_count? number  — number of hashtags to append (default: 3)
 *
 * Returns:
 *   { text, hashtags, platform, char_count }
 */
export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const body = await request.json();
  const {
    topic,
    platform,
    tone = 'informational',
    news_headline,
    news_summary,
    hashtag_count = 3,
  } = body;

  if (!topic) {
    return NextResponse.json({ error: 'topic is required' }, { status: 400 });
  }
  if (!platform) {
    return NextResponse.json({ error: 'platform is required' }, { status: 400 });
  }

  const spec = PLATFORM_SPECS[platform.toLowerCase()] ?? PLATFORM_SPECS.twitter;

  // Build the Gemini prompt
  const newsContext = news_headline
    ? `\n\nBreaking news context:\nHeadline: ${news_headline}${news_summary ? `\nSummary: ${news_summary}` : ''}`
    : '';

  const prompt = `You are a social media writer for a nonprofit student debt advocacy organization.

Platform: ${platform.toUpperCase()}
Topic: ${topic}
Tone: ${tone}${newsContext}

Platform guidance: ${spec.guidance}

Instructions:
- Write ONLY the post text (no labels, no explanations, no quotes around the output).
- Append exactly ${hashtag_count} relevant hashtags as a JSON array on the very last line in this exact format:
  HASHTAGS: ["#tag1","#tag2","#tag3"]
- Do not include the HASHTAGS line in the visible post body character count.
- The post body must stay within ${spec.char_limit} characters.
- Focus on student debt, loan forgiveness, borrower rights, or education policy as relevant.
- Never mention specific dollar amounts, internal systems, or vendor names.`;

  let geminiResponse: Response;
  try {
    geminiResponse = await fetch(getGeminiUrl(), {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{ parts: [{ text: prompt }] }],
        generationConfig: {
          temperature: 0.7,
          maxOutputTokens: 1024,
        },
      }),
    });
  } catch (err) {
    console.error('[ai-compose] Gemini fetch error:', (err as Error).message);
    return NextResponse.json({ error: 'AI service unavailable' }, { status: 502 });
  }

  if (!geminiResponse.ok) {
    const errText = await geminiResponse.text().catch(() => '');
    console.error('[ai-compose] Gemini error response:', geminiResponse.status, errText);
    return NextResponse.json(
      { error: `AI generation failed (${geminiResponse.status})` },
      { status: 502 }
    );
  }

  const json = await geminiResponse.json();
  const rawText: string =
    json?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';

  if (!rawText) {
    return NextResponse.json({ error: 'AI returned empty response' }, { status: 502 });
  }

  // Parse the HASHTAGS line from the end of the response
  // Match the HASHTAGS line at the end — avoid the /s flag (ES2017 target)
  const hashtagLineMatch = rawText.match(/HASHTAGS:\s*(\[[\s\S]*?\])\s*$/);
  let hashtags: string[] = [];
  let postText = rawText;

  if (hashtagLineMatch) {
    try {
      hashtags = JSON.parse(hashtagLineMatch[1]);
    } catch {
      // Fallback: extract #words manually
      hashtags = (hashtagLineMatch[1].match(/#\w+/g) ?? []).slice(0, hashtag_count);
    }
    // Remove the HASHTAGS metadata line from the visible post text
    postText = rawText.slice(0, hashtagLineMatch.index).trimEnd();
  }

  // Enforce char limit as a safety net
  if (postText.length > spec.char_limit) {
    postText = postText.slice(0, spec.char_limit - 1).trimEnd();
  }

  return NextResponse.json({
    text: postText,
    hashtags,
    platform,
    char_count: postText.length,
  });
}