← 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}`;
}
}