← back to Norma

app/api/email-analyzer/rewrite/route.ts

200 lines

/**
 * POST /api/email-analyzer/rewrite
 * AI-powered email rewriting via Gemini 2.0 Flash.
 * Accepts the original email, current version, chat instruction, and optional tone/paragraph overrides.
 */

import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/require-role';
import { checkRateLimit } from '@/lib/rate-limit';
import { query } from '@/lib/db';

/**
 * Pull active Universal References and format them for the prompt.
 * These behave like "Claude memory for the email-analyzer modality" — they
 * ride along on every rewrite without per-session setup.
 */
async function buildUniversalRefBlock(): Promise<string> {
  try {
    const { rows } = await query(
      `SELECT title, content, tone, tags
         FROM email_analyzer_universal_refs
        WHERE is_active = TRUE
        ORDER BY created_at DESC
        LIMIT 25`,
    );
    if (rows.length === 0) return '';
    const sections = rows.map((r) => {
      const tagLine = Array.isArray(r.tags) && r.tags.length ? ` [${r.tags.join(', ')}]` : '';
      const toneLine = r.tone ? ` (${r.tone})` : '';
      return `### ${r.title}${toneLine}${tagLine}\n${String(r.content).slice(0, 1200)}`;
    });
    return ['## Universal Email References (always-on)', ...sections].join('\n\n');
  } catch (err) {
    console.error('[rewrite] universal-refs fetch failed:', (err as Error).message);
    return '';
  }
}

const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';

export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const rl = checkRateLimit(request, 10, 60_000);
  if (rl.limited) {
    return NextResponse.json(
      { error: 'Too many AI requests. Try again later.' },
      { status: 429 },
    );
  }

  try {
    const body = await request.json();
    const {
      originalEmail,
      currentVersion,
      instruction,
      chatHistory = [],
      tone,
      paragraphCount,
      referenceContext,
    } = body;

    if (!currentVersion || typeof currentVersion !== 'string') {
      return NextResponse.json({ error: 'currentVersion is required' }, { status: 400 });
    }
    if (!instruction || typeof instruction !== 'string') {
      return NextResponse.json({ error: 'instruction is required' }, { status: 400 });
    }

    if (!GEMINI_API_KEY) {
      return NextResponse.json({ error: 'Gemini API key not configured' }, { status: 500 });
    }

    // Build conversation context from chat history (last 10 messages)
    const recentHistory = chatHistory.slice(-10);
    const historyContext = recentHistory.length > 0
      ? `\n\nPrevious conversation:\n${recentHistory.map((m: { role: string; content: string }) => `${m.role}: ${m.content}`).join('\n')}`
      : '';

    const toneInstruction = tone ? `\nApply a ${tone} tone throughout.` : '';
    const paragraphInstruction = paragraphCount ? `\nTarget approximately ${paragraphCount} paragraphs.` : '';

    // Universal refs always ride along — they're the "modality-level memory".
    // Session refs (referenceContext) layer on top of them.
    const universalBlock = await buildUniversalRefBlock();
    const combinedRefs = [universalBlock, referenceContext].filter(Boolean).join('\n\n');
    const referenceBlock = combinedRefs
      ? `\n\nREFERENCE MATERIALS (use these to enrich the email with relevant facts, data, quotes, and links):\n---\n${combinedRefs}\n---`
      : '';

    const prompt = `You are an expert email editor and rewriter for nonprofit organizations. You help refine email communications to be more effective.

ORIGINAL EMAIL (for reference):
---
${originalEmail || currentVersion}
---

CURRENT WORKING VERSION:
---
${currentVersion}
---
${historyContext}

USER INSTRUCTION: ${instruction}
${toneInstruction}${paragraphInstruction}${referenceBlock}

RULES:
- Maintain the core message and key information from the current version
- Apply the requested changes precisely
- Preserve any names, dates, URLs, and specific facts from the original
- Return clean HTML suitable for email (use <p>, <strong>, <em>, <ul>, <li>, <a> tags)
- Do NOT include <html>, <head>, <body>, or <style> tags
- Do NOT include email headers, subject lines, or signatures
- Make the writing natural and human-sounding
- Keep the email professional and appropriate for nonprofit communications
- If reference materials are provided, incorporate relevant facts, statistics, quotes, and links from them naturally into the email
- Cite sources from reference materials when appropriate (e.g., "According to [source]...")

Return ONLY the rewritten email body as HTML. Do not include any explanation or commentary.`;

    const res = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        contents: [{ parts: [{ text: prompt }] }],
        generationConfig: { temperature: 0.7, maxOutputTokens: 4096 },
      }),
    });

    if (!res.ok) {
      const errText = await res.text();
      console.error('[email-analyzer/rewrite] Gemini error:', res.status, errText);
      return NextResponse.json(
        { error: `AI rewrite failed (${res.status})` },
        { status: 502 },
      );
    }

    const data = await res.json();
    let rewrittenEmail = data?.candidates?.[0]?.content?.parts?.[0]?.text || '';

    // Strip code fences if present
    rewrittenEmail = rewrittenEmail.replace(/^```html?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim();

    // Count paragraphs and words
    const pCount = (rewrittenEmail.match(/<p[\s>]/gi) || []).length || rewrittenEmail.split(/\n\n+/).length;
    const plainText = rewrittenEmail.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
    const wCount = plainText.split(/\s+/).filter(Boolean).length;

    // Detect tone from content
    const detectedTone = tone || 'professional';

    // Generate a brief summary of changes
    const summaryPrompt = `In 8 words or fewer, describe what changed between these two email versions:

BEFORE:
${currentVersion.substring(0, 500)}

AFTER:
${rewrittenEmail.substring(0, 500)}

USER ASKED FOR: ${instruction}

Return ONLY the brief description, nothing else.`;

    let summary = instruction.length <= 50 ? instruction : instruction.substring(0, 50) + '...';
    try {
      const summaryRes = await fetch(`${GEMINI_URL}?key=${GEMINI_API_KEY}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          contents: [{ parts: [{ text: summaryPrompt }] }],
          generationConfig: { temperature: 0.3, maxOutputTokens: 50 },
        }),
      });
      if (summaryRes.ok) {
        const sData = await summaryRes.json();
        const sText = sData?.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
        if (sText && sText.length < 80) summary = sText;
      }
    } catch {
      // Keep the fallback summary
    }

    return NextResponse.json({
      rewrittenEmail,
      summary,
      tone: detectedTone,
      paragraphCount: pCount,
      wordCount: wCount,
    });
  } catch (err) {
    console.error('[email-analyzer/rewrite] error:', (err as Error).message);
    return NextResponse.json({ error: 'Email rewrite failed' }, { status: 500 });
  }
}