← back to Norma

app/api/orgs/match/route.ts

273 lines

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

/**
 * Tag normalization: bridges vocabulary gaps between news tags (hyphenated)
 * and org key_issues (underscored), plus maps related terms.
 */
const TAG_ALIASES: Record<string, string[]> = {
  'student_debt': ['student-debt', 'student-loan', 'student-loans', 'student-loan-forgiveness', 'pslf-public-service-loan-forgiveness', 'SAVE-plan', 'borrower-experience', 'repayment', 'default', 'collections', 'forgiveness'],
  'education': ['education', 'higher-education', 'college', 'university', 'tuition'],
  'healthcare': ['healthcare', 'health', 'public-health', 'mental-health', 'medicaid', 'medicare'],
  'economic_justice': ['economic-justice', 'poverty', 'inequality', 'wages', 'housing', 'cost-of-living'],
  'civil_rights': ['civil-rights', 'voting-rights', 'discrimination', 'equality', 'dei'],
  'climate': ['climate', 'environment', 'clean-energy', 'emissions', 'sustainability'],
  'immigration': ['immigration', 'daca', 'border', 'refugee', 'asylum'],
  'criminal_justice': ['criminal-justice', 'police-reform', 'incarceration', 'sentencing'],
  'voting_rights': ['voting-rights', 'election', 'voter-registration', 'gerrymandering'],
  'labor': ['labor', 'unions', 'workers-rights', 'minimum-wage', 'gig-economy'],
  'gun_control': ['gun-control', 'gun-violence', 'firearms', 'mass-shooting'],
  'foreign_policy': ['foreign-policy', 'iran', 'diplomacy', 'sanctions', 'military'],
  'child_safety': ['child-safety', 'children', 'youth', 'child-welfare'],
  'technology': ['technology', 'ai', 'privacy', 'data-protection', 'social-media'],
  'policy': ['policy', 'legislation', 'regulation', 'legal', 'congress'],
};

/** Normalize a tag to its canonical issue key */
function normalizeTag(tag: string): string[] {
  const lower = tag.toLowerCase().trim();
  const matches: string[] = [];
  for (const [canonical, aliases] of Object.entries(TAG_ALIASES)) {
    if (canonical === lower || lower.replace(/-/g, '_') === canonical) {
      matches.push(canonical);
    } else if (aliases.some(a => a === lower || lower.includes(a) || a.includes(lower))) {
      matches.push(canonical);
    }
  }
  return matches.length > 0 ? matches : [lower.replace(/-/g, '_')];
}

/** Political alignment score: how compatible is the org with the topic sentiment */
function politicalScore(orgLeaning: string | null, topicSentiment: number | null): number {
  if (!orgLeaning) return 0.5; // neutral/unknown = partial match
  // sentiment: -1 (progressive) to +1 (conservative), 0 = neutral
  const sent = topicSentiment ?? 0;
  const leanMap: Record<string, number> = {
    progressive: -0.8, center_left: -0.4, moderate: 0, nonpartisan: 0,
    center_right: 0.4, conservative: 0.8,
  };
  const orgPos = leanMap[orgLeaning] ?? 0;
  // Closer positions = higher score. Max distance is 1.6
  const distance = Math.abs(orgPos - sent);
  return Math.max(0, 1 - distance / 1.6);
}

/** Urgency compatibility: org threshold vs topic urgency */
function urgencyScore(orgThreshold: string | null, topicUrgency: string | null): number {
  if (!orgThreshold || !topicUrgency) return 0.5;
  const levels: Record<string, number> = { low: 1, moderate: 2, high: 3, critical: 4 };
  const orgLevel = levels[orgThreshold] ?? 2;
  const topicLevel = levels[topicUrgency] ?? 2;
  // Orgs with lower thresholds are willing to act on less urgent topics
  if (topicLevel >= orgLevel) return 1.0; // topic meets or exceeds threshold
  return 0.3; // topic below org's threshold
}

/** Capacity signal from supporter count and outreach channels */
function capacityScore(supporterCount: string | null, channels: string[] | null): number {
  const sizeMap: Record<string, number> = {
    'under_100': 0.2, '100-500': 0.4, '500-1000': 0.5,
    '1000-5000': 0.7, '5000-10000': 0.85, '10000_plus': 1.0,
  };
  const sizeScore = sizeMap[supporterCount ?? ''] ?? 0.3;
  const channelCount = (channels || []).length;
  const channelScore = Math.min(1, channelCount / 5);
  return sizeScore * 0.6 + channelScore * 0.4;
}

/**
 * GET /api/orgs/match
 * Match organizations to a news topic or set of tags.
 *
 * Query params:
 *   news_id     - UUID of a news_items row (gets tags from DB)
 *   tags        - comma-separated tags (alternative to news_id)
 *   urgency     - topic urgency: low, moderate, high, critical
 *   sentiment   - topic political sentiment: -1.0 to +1.0
 *   geo_state   - topic geographic state focus
 *   action_type - petition, rally, email_campaign, testimony, etc.
 *   limit       - max results (default 50)
 *   min_score   - minimum match score 0-100 (default 10)
 */
export async function GET(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const { searchParams } = new URL(request.url);
    const newsId = searchParams.get('news_id');
    const tagsParam = searchParams.get('tags');
    const urgency = searchParams.get('urgency');
    const sentiment = searchParams.get('sentiment') ? parseFloat(searchParams.get('sentiment')!) : null;
    const geoState = searchParams.get('geo_state');
    const actionType = searchParams.get('action_type');
    const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
    const minScore = parseInt(searchParams.get('min_score') || '10');

    // Resolve topic tags
    let topicTags: string[] = [];
    let newsHeadline: string | null = null;

    if (newsId) {
      const newsResult = await query(
        'SELECT headline, tags, raw_data FROM news_items WHERE id = $1',
        [newsId],
      );
      if (newsResult.rowCount === 0) {
        return NextResponse.json({ error: 'News item not found' }, { status: 404 });
      }
      topicTags = newsResult.rows[0].tags || [];
      newsHeadline = newsResult.rows[0].headline;
    } else if (tagsParam) {
      topicTags = tagsParam.split(',').map(t => t.trim()).filter(Boolean);
    } else {
      return NextResponse.json({ error: 'Provide news_id or tags parameter' }, { status: 400 });
    }

    // Normalize topic tags to canonical issue keys
    const canonicalIssues = new Set<string>();
    for (const tag of topicTags) {
      for (const norm of normalizeTag(tag)) {
        canonicalIssues.add(norm);
      }
    }
    const issueArray = Array.from(canonicalIssues);

    // Fetch all orgs with at least some data
    const orgsResult = await query(
      `SELECT id, name, key_issues, focus_populations, political_leaning,
              state, city, metro_area, type, category, email, phone, website,
              advocacy_intensity, bipartisan_willingness, supporter_count,
              outreach_channels, response_speed, urgency_threshold,
              sentiment_preference, preferred_actions, geo_focus,
              onboard_status, description, mission
       FROM organizations
       WHERE key_issues IS NOT NULL AND array_length(key_issues, 1) > 0
       ORDER BY name`,
    );

    // Score each org
    const scored: Array<{
      org: Record<string, unknown>;
      score: number;
      factors: Record<string, number>;
      reasons: string[];
    }> = [];

    for (const org of orgsResult.rows) {
      const orgIssues = (org.key_issues || []).map((i: string) => i.toLowerCase().replace(/-/g, '_'));
      const reasons: string[] = [];

      // 1. Issue overlap (35%)
      const issueOverlap = issueArray.filter(ci =>
        orgIssues.some((oi: string) => oi === ci || oi.includes(ci) || ci.includes(oi)),
      ).length;
      const issueFactor = issueOverlap / Math.max(issueArray.length, 1);
      if (issueOverlap > 0) reasons.push(`${issueOverlap} issue${issueOverlap > 1 ? 's' : ''} in common`);

      // 2. Geographic alignment (20%)
      let geoFactor = 0.3; // base: unknown geo = partial
      if (geoState && org.state) {
        if (org.state.toLowerCase() === geoState.toLowerCase()) {
          geoFactor = 1.0;
          reasons.push('Same state');
        } else {
          geoFactor = 0.15;
        }
      } else if (org.geo_focus === 'national') {
        geoFactor = 0.7;
        reasons.push('National focus');
      }

      // 3. Political fit (15%)
      const polFactor = politicalScore(org.political_leaning, sentiment);
      if (polFactor > 0.7) reasons.push('Political alignment');

      // 4. Urgency fit (10%)
      const urgFactor = urgencyScore(org.urgency_threshold, urgency);
      if (urgFactor > 0.7) reasons.push('Urgency match');

      // 5. Action alignment (10%)
      let actionFactor = 0.5;
      if (actionType && org.preferred_actions && org.preferred_actions.length > 0) {
        const acts = (org.preferred_actions as string[]).map(a => a.toLowerCase());
        if (acts.includes(actionType.toLowerCase())) {
          actionFactor = 1.0;
          reasons.push('Preferred action type');
        } else {
          actionFactor = 0.2;
        }
      }

      // 6. Capacity signal (10%)
      const capFactor = capacityScore(org.supporter_count, org.outreach_channels);
      if (capFactor > 0.7) reasons.push('Strong outreach capacity');

      // Weighted score
      const raw = (
        issueFactor * 35 +
        geoFactor * 20 +
        polFactor * 15 +
        urgFactor * 10 +
        actionFactor * 10 +
        capFactor * 10
      );
      const score = Math.round(Math.min(100, raw));

      if (score >= minScore) {
        scored.push({
          org: {
            id: org.id,
            name: org.name,
            type: org.type,
            category: org.category,
            political_leaning: org.political_leaning,
            state: org.state,
            city: org.city,
            key_issues: org.key_issues,
            email: org.email,
            phone: org.phone,
            website: org.website,
            mission: org.mission,
            description: org.description,
            onboard_status: org.onboard_status,
            supporter_count: org.supporter_count,
            advocacy_intensity: org.advocacy_intensity,
          },
          score,
          factors: {
            issue_overlap: Math.round(issueFactor * 100),
            geographic: Math.round(geoFactor * 100),
            political_fit: Math.round(polFactor * 100),
            urgency_fit: Math.round(urgFactor * 100),
            action_alignment: Math.round(actionFactor * 100),
            capacity: Math.round(capFactor * 100),
          },
          reasons,
        });
      }
    }

    // Sort by score descending
    scored.sort((a, b) => b.score - a.score);

    return NextResponse.json({
      topic: {
        news_id: newsId || null,
        headline: newsHeadline,
        tags: topicTags,
        canonical_issues: issueArray,
        urgency: urgency || null,
        sentiment: sentiment ?? null,
        geo_state: geoState || null,
      },
      matches: scored.slice(0, limit),
      total: scored.length,
    });
  } catch (err) {
    console.error('[api/orgs/match] Error:', (err as Error).message);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}