← back to Norma

agents/onboard-agent/lib/foundation-matcher.js

70 lines

/**
 * Foundation Matcher
 *
 * Matches discovered nonprofit organizations with foundations
 * based on overlapping focus areas and geographic alignment.
 */

/**
 * Find foundations that match an organization's issue areas and location.
 *
 * @param {Object} pool - pg Pool instance (or query function container)
 * @param {string[]} orgIssues - Issue areas for the org (from NTEE mapping)
 * @param {string} orgState - State abbreviation of the org (e.g. 'CA', 'NY')
 * @returns {Promise<Array<{ foundation_id: string, foundation_name: string, overlap_areas: string[], geo_match: boolean, score: number }>>}
 */
async function matchFoundations(pool, orgIssues, orgState) {
  // Query active foundations, ordered by annual giving
  const { rows } = await pool.query(`
    SELECT id, name, focus_areas, geographic_focus, annual_giving
    FROM foundations
    WHERE 1=1
    ORDER BY annual_giving DESC NULLS LAST
    LIMIT 100
  `);

  const matches = [];

  for (const f of rows) {
    const fAreas = Array.isArray(f.focus_areas) ? f.focus_areas : [];
    if (fAreas.length === 0) continue;

    // Find overlapping focus areas
    const overlap = fAreas.filter(a =>
      orgIssues.some(i =>
        a.toLowerCase().includes(i) || i.includes(a.toLowerCase())
      )
    );

    if (overlap.length === 0) continue;

    // Check geographic match
    const geoFocus = Array.isArray(f.geographic_focus) ? f.geographic_focus : [];
    const geoMatch = geoFocus.length > 0
      ? (geoFocus.some(g =>
          g.toLowerCase().includes((orgState || '').toLowerCase()) ||
          g.toLowerCase().includes('national') ||
          g.toLowerCase() === 'all'
        ))
      : false;

    // Score: 60% focus area overlap, 40% geographic alignment
    const overlapScore = (overlap.length / Math.max(fAreas.length, 1)) * 60;
    const geoScore = geoMatch ? 40 : 0;

    matches.push({
      foundation_id: f.id,
      foundation_name: f.name,
      overlap_areas: overlap,
      geo_match: geoMatch,
      annual_giving: f.annual_giving,
      score: overlapScore + geoScore,
    });
  }

  // Return top 10 sorted by score descending
  return matches.sort((a, b) => b.score - a.score).slice(0, 10);
}

module.exports = { matchFoundations };