← back to Norma

app/api/petitions/suggest/route.ts

221 lines

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

interface PetitionRow {
  id: string;
  title: string;
  url: string;
  platform: string;
  description: string | null;
  status: string;
  signature_count: number | null;
  signature_goal: number | null;
  target: string | null;
  category: string | null;
  tags: string[];
  ai_suggestion: string | null;
  ai_urgency: number | null;
  last_checked_at: string | null;
  is_featured: boolean;
  created_at: string;
  updated_at: string;
}

interface NewsRow {
  id: string;
  headline: string;
  tags: string[];
  published_at: string | null;
}

interface Suggestion {
  petition: PetitionRow;
  reason: string;
  recommended_lane: string;
  email_angle: string;
  score: number;
}

/**
 * POST /api/petitions/suggest
 * AI suggestion endpoint (Phase 1: rule-based tag matching).
 *
 * Rules:
 * 1. If any news item in the last 7 days has tags matching a petition's tags, boost that petition
 * 2. Featured petition always appears first
 * 3. Active petitions only
 * 4. Return top 3 suggestions
 *
 * Returns: { suggestions: [...] } where each suggestion has:
 *   petition, reason, recommended_lane, email_angle, score
 */
export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

  try {
    // Fetch active petitions for this org
    const petitionsResult = await query<PetitionRow>(
      `SELECT * FROM petitions WHERE status = 'active' AND org_id = $1 ORDER BY ai_urgency DESC NULLS LAST`,
      [orgId]
    );

    if (petitionsResult.rows.length === 0) {
      return NextResponse.json({ suggestions: [] });
    }

    // Fetch recent news items (last 7 days) that have tags for this org
    const newsResult = await query<NewsRow>(
      `SELECT id, headline, tags, published_at
       FROM news_items
       WHERE published_at >= NOW() - INTERVAL '7 days'
         AND tags IS NOT NULL
         AND array_length(tags, 1) > 0
         AND org_id = $1
       ORDER BY published_at DESC`,
      [orgId]
    );

    const recentNews = newsResult.rows;

    // Build a set of all recent news tags for fast lookup
    const newsTagSet = new Set<string>();
    const newsTagToHeadlines = new Map<string, string[]>();

    for (const newsItem of recentNews) {
      if (newsItem.tags) {
        for (const tag of newsItem.tags) {
          const normalizedTag = tag.toLowerCase().trim();
          newsTagSet.add(normalizedTag);

          if (!newsTagToHeadlines.has(normalizedTag)) {
            newsTagToHeadlines.set(normalizedTag, []);
          }
          newsTagToHeadlines.get(normalizedTag)!.push(newsItem.headline);
        }
      }
    }

    // Score each petition
    const scoredPetitions: Suggestion[] = [];

    for (const petition of petitionsResult.rows) {
      let score = petition.ai_urgency ?? 0;
      const matchedTags: string[] = [];
      const matchedHeadlines = new Set<string>();

      // Rule 1: Boost petitions whose tags match recent news tags
      if (petition.tags) {
        for (const tag of petition.tags) {
          const normalizedTag = tag.toLowerCase().trim();
          if (newsTagSet.has(normalizedTag)) {
            score += 0.15; // Each matching tag adds 0.15 to the score
            matchedTags.push(tag);
            const headlines = newsTagToHeadlines.get(normalizedTag);
            if (headlines) {
              for (const h of headlines) {
                matchedHeadlines.add(h);
              }
            }
          }
        }
      }

      // Rule 2: Featured petition gets a significant boost
      if (petition.is_featured) {
        score += 0.5;
      }

      // Build the reason string
      let reason: string;
      if (matchedTags.length > 0) {
        const headlineList = Array.from(matchedHeadlines).slice(0, 2);
        reason = `Tags [${matchedTags.join(', ')}] match recent news: "${headlineList.join('", "')}".`;
        if (petition.is_featured) {
          reason += ' Currently featured petition.';
        }
      } else if (petition.is_featured) {
        reason = 'Currently featured petition with high urgency score.';
      } else {
        reason = `High urgency score (${petition.ai_urgency ?? 0}) based on petition content and status.`;
      }

      // Build the email angle from ai_suggestion or generate a basic one
      let emailAngle: string;
      if (petition.ai_suggestion) {
        emailAngle = petition.ai_suggestion;
      } else {
        emailAngle = buildDefaultEmailAngle(petition);
      }

      // Determine recommended lane
      // 'A' (Action) is the default for petitions since they are calls to action
      // 'B' (Policy) if the category is consumer_protection or if the petition targets Congress
      let recommendedLane = 'A';
      if (
        petition.category === 'consumer_protection' ||
        (petition.target && petition.target.toLowerCase().includes('congress'))
      ) {
        recommendedLane = 'B';
      }

      scoredPetitions.push({
        petition,
        reason,
        recommended_lane: recommendedLane,
        email_angle: emailAngle,
        score,
      });
    }

    // Sort by score descending; featured always first (already boosted by +0.5)
    scoredPetitions.sort((a, b) => {
      // Featured always first regardless of score
      if (a.petition.is_featured && !b.petition.is_featured) return -1;
      if (!a.petition.is_featured && b.petition.is_featured) return 1;
      return b.score - a.score;
    });

    // Return top 3
    const top3 = scoredPetitions.slice(0, 3).map(({ petition, reason, recommended_lane, email_angle, score }) => ({
      petition,
      reason,
      recommended_lane,
      email_angle,
      score: Math.round(score * 100) / 100,
    }));

    return NextResponse.json({ suggestions: top3 });
  } catch (err) {
    console.error('[api/petitions/suggest] POST error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to generate petition suggestions' }, { status: 500 });
  }
}

/**
 * Build a default email angle when ai_suggestion is not set.
 */
function buildDefaultEmailAngle(petition: PetitionRow): string {
  const target = petition.target || 'decision-makers';
  const sigInfo = petition.signature_count
    ? ` Already ${petition.signature_count.toLocaleString()} signatures strong.`
    : '';

  switch (petition.category) {
    case 'debt_cancellation':
      return `Frame around the urgency of debt cancellation. Direct readers to sign the petition targeting ${target}.${sigInfo}`;
    case 'consumer_protection':
      return `Highlight consumer protection gaps. Tie to legislative action targeting ${target}.${sigInfo}`;
    case 'collections':
      return `Focus on predatory collection practices. Build urgency around protecting affected individuals from aggressive collectors.${sigInfo}`;
    case 'repayment':
      return `Address repayment challenges and the need for flexible options. Connect to ongoing policy discussions.${sigInfo}`;
    default:
      return `Use this petition as a call-to-action targeting ${target}. Pair with relevant news for maximum impact.${sigInfo}`;
  }
}