import { json } from './_session.js'; import { changeFetchFirst, getChangeConfig, normalizeChangeList } from './_change.js'; import { getGivethProjects, withGivethPageMeta } from './_giveth.js'; import { hasTgbConfig, listTgbOrganizations } from './_tgb.js'; const DEFAULT_SEARCH_TERMS = [ 'GiveDirectly', 'Partners In Health', 'water', 'global health', 'poverty' ]; const MAX_SOULWALL_CARDS = 3; function splitTerms(value) { return String(value || '') .split(',') .map((term) => term.trim()) .filter(Boolean); } function getNonprofitId(nonprofit) { return String( nonprofit.id || nonprofit.nonprofit_id || nonprofit.nonprofitId || nonprofit.ein || '' ); } function getLogo(nonprofit) { return ( nonprofit.logo_url || nonprofit.logoUrl || nonprofit.image_url || nonprofit.imageUrl || nonprofit.cover_image_url || nonprofit.coverImageUrl || '' ); } function getDescription(nonprofit) { return ( nonprofit.description || nonprofit.mission || nonprofit.tagline || nonprofit.category || 'Verified nonprofit available through the Change charity rail.' ); } function normalizeNonprofit(nonprofit) { const id = getNonprofitId(nonprofit); const name = nonprofit.name || nonprofit.display_name || nonprofit.displayName || nonprofit.legal_name || id; return { provider: 'change', id, name, description: getDescription(nonprofit), ein: nonprofit.ein || '', website: nonprofit.website || nonprofit.website_url || nonprofit.websiteUrl || '', logoUrl: getLogo(nonprofit), supporterCount: Number(nonprofit.donation_count || nonprofit.donationCount || nonprofit.contribution_count || 0), category: nonprofit.category || nonprofit.ntee_major_group || nonprofit.nteeMajorGroup || '' }; } async function searchChangeNonprofits(env, searchTerm) { const paths = [ `/nonprofits/search?term=${encodeURIComponent(searchTerm)}`, `/nonprofits/search?query=${encodeURIComponent(searchTerm)}`, `/nonprofits?search=${encodeURIComponent(searchTerm)}`, `/nonprofits?query=${encodeURIComponent(searchTerm)}` ]; const payload = await changeFetchFirst(env, paths); return normalizeChangeList(payload); } function sortCharities(charities) { return charities.sort((a, b) => { const aHasLogo = a.logoUrl ? 1 : 0; const bHasLogo = b.logoUrl ? 1 : 0; if (aHasLogo !== bHasLogo) return bHasLogo - aHasLogo; return String(a.name).localeCompare(String(b.name)); }); } async function loadGivethCharities(env) { const projects = getGivethProjects(env); const shouldFetchPageMeta = env.GIVETH_FETCH_PAGE_META === '1'; return shouldFetchPageMeta ? Promise.all(projects.map(withGivethPageMeta)) : projects; } function dedupeByProviderKey(charities) { const seen = new Set(); return charities.filter((charity) => { const key = `${String(charity.provider || '').toLowerCase()}::${String(charity.id || charity.slug || charity.name || '').toLowerCase()}`; if (!key || seen.has(key)) return false; seen.add(key); return true; }); } export async function onRequestGet({ env }) { const rail = String(env.SOULWALL_CHARITY_RAIL || 'giveth').trim().toLowerCase(); if (rail === 'giveth') { const charities = (await loadGivethCharities(env)).slice(0, MAX_SOULWALL_CARDS); return json({ provider: 'giveth', charities, message: 'Giveth project feed loaded. Donations happen on official Giveth project pages; SoulWall verifies the submitted transaction hash before unlocking.' }); } if (rail === 'tgb') { if (!hasTgbConfig(env)) { return json({ provider: 'tgb', charities: [], message: 'The Giving Block credentials are missing. Set SOULWALL_TGB_LOGIN and SOULWALL_TGB_PASSWORD.' }, 503); } const charities = sortCharities(await listTgbOrganizations(env)).slice(0, MAX_SOULWALL_CARDS); return json({ provider: 'tgb', charities, message: charities.length ? 'Live The Giving Block nonprofit feed loaded.' : 'The Giving Block nonprofit feed returned no organizations.' }, charities.length ? 200 : 503); } if (rail === 'hybrid' || rail === 'mixed') { const charities = []; const warnings = []; try { charities.push(...(await loadGivethCharities(env))); } catch (error) { warnings.push({ provider: 'giveth', message: error.message }); } if (hasTgbConfig(env)) { try { charities.push(...(await listTgbOrganizations(env))); } catch (error) { warnings.push({ provider: 'tgb', message: error.message }); } } else { warnings.push({ provider: 'tgb', message: 'The Giving Block credentials are missing.' }); } const deduped = sortCharities(dedupeByProviderKey(charities)).slice(0, MAX_SOULWALL_CARDS); return json({ provider: 'hybrid', charities: deduped, warnings, message: deduped.length ? 'SoulWall loaded both the curated Giveth set and The Giving Block variety feed.' : 'No charity feeds were available.' }, deduped.length ? 200 : 503); } if (!getChangeConfig(env)) { return json({ provider: 'change', charities: [], message: 'Change API key missing. Set CHANGE_SECRET_KEY, or CHANGE_PUBLIC_KEY plus CHANGE_SECRET_KEY.' }, 503); } const searchTerms = splitTerms(env.CHANGE_SEARCH_TERMS); const terms = searchTerms.length ? searchTerms : DEFAULT_SEARCH_TERMS; const byId = new Map(); const warnings = []; for (const term of terms) { try { const nonprofits = await searchChangeNonprofits(env, term); for (const nonprofit of nonprofits) { const normalized = normalizeNonprofit(nonprofit); if (!normalized.id || !normalized.name) continue; if (!byId.has(normalized.id)) byId.set(normalized.id, normalized); } } catch (error) { warnings.push({ term, message: error.message }); } } const charities = sortCharities(Array.from(byId.values())).slice(0, MAX_SOULWALL_CARDS); return json({ provider: 'change', charities, warnings, message: charities.length ? 'Live Change nonprofit feed loaded.' : 'Change nonprofit feed returned no charities for the configured search terms.' }, charities.length ? 200 : 503); }