← back to Norma

app/api/ingest/charity-navigator/route.ts

130 lines

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

/**
 * POST /api/ingest/charity-navigator
 *
 * Ingests organization ratings from Charity Navigator.
 * Body: { keywords?: string[] }
 *
 * Upserts into charity_ratings via ON CONFLICT (ein) DO UPDATE.
 */
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;
  const stats = { ingested: 0, errors: [] as string[] };

  try {
    const body = await request.json().catch(() => ({}));
    const keywords: string[] = body.keywords ?? [
      'education',
      'community development',
      'human services',
    ];

    // De-duplicate organizations across keyword searches
    const allOrgs = new Map<string, CharityNavOrg>();

    for (const keyword of keywords) {
      try {
        const orgs = await searchOrganizations(keyword, 50);
        for (const org of orgs) {
          if (org.ein && !allOrgs.has(org.ein)) {
            allOrgs.set(org.ein, org);
          }
        }
      } catch (err) {
        stats.errors.push(`search_"${keyword}": ${(err as Error).message}`);
      }
    }

    // Upsert each organization
    for (const org of allOrgs.values()) {
      try {
        await query(
          `INSERT INTO charity_ratings (
            ein, org_name, overall_rating, financial_rating,
            accountability_rating, category, cause, tagline,
            charity_navigator_url, raw_json, org_id
          ) VALUES (
            $1, $2, $3, $4,
            $5, $6, $7, $8,
            $9, $10, $11
          )
          ON CONFLICT (ein) DO UPDATE SET
            org_name = EXCLUDED.org_name,
            overall_rating = EXCLUDED.overall_rating,
            financial_rating = EXCLUDED.financial_rating,
            accountability_rating = EXCLUDED.accountability_rating,
            category = EXCLUDED.category,
            cause = EXCLUDED.cause,
            tagline = EXCLUDED.tagline,
            charity_navigator_url = EXCLUDED.charity_navigator_url,
            raw_json = EXCLUDED.raw_json,
            updated_at = NOW()`,
          [
            org.ein,
            org.orgName,
            org.overallRating,
            org.financialRating,
            org.accountabilityRating,
            org.category,
            org.cause,
            org.tagline,
            org.charityNavigatorUrl,
            JSON.stringify(org.rawJson),
            orgId,
          ],
        );
        stats.ingested++;
      } catch (err) {
        stats.errors.push(`insert_${org.ein}: ${(err as Error).message}`);
      }
    }
  } catch (err) {
    stats.errors.push('charity_navigator_ingest: ' + (err as Error).message);
  }

  return NextResponse.json({
    success: true,
    ingested: stats.ingested,
    errors: stats.errors,
    message: `Ingested ${stats.ingested} Charity Navigator organizations`,
  });
}

/* ─── GET: Return ingested Charity Navigator ratings ─────────────────────── */
export async function GET(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const { rows } = await query(`
      SELECT id, ein, org_name, overall_rating, financial_rating,
             accountability_rating, category, cause, tagline,
             charity_navigator_url, total_revenue, total_expenses,
             program_expense_ratio, admin_expense_ratio,
             fundraising_expense_ratio, created_at, updated_at
      FROM charity_ratings
      ORDER BY overall_rating DESC NULLS LAST, org_name ASC
      LIMIT 200
    `);

    return NextResponse.json({
      ratings: rows,
      total: rows.length,
    });
  } catch (err) {
    console.error('[ingest/charity-navigator] GET error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to fetch Charity Navigator ratings' }, { status: 500 });
  }
}