← 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,
});
}